From 12a307f6943e3061ea5f5d07f04968e40b4f8ccc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:18:45 +0300 Subject: [PATCH 01/31] Add native file open/save dialog API with filters, options, hints and previews Co-Authored-By: Claude Opus 5.5 --- .../impl/CodenameOneImplementation.java | 55 +++ .../com/codename1/impl/FileDialogSupport.java | 217 +++++++++++ .../io/filechooser/FileDialogFeatures.java | 54 +++ .../io/filechooser/FileDialogHints.java | 68 ++++ .../io/filechooser/FileDialogOption.java | 170 +++++++++ .../io/filechooser/FileDialogRequest.java | 349 ++++++++++++++++++ .../io/filechooser/FileDialogResult.java | 156 ++++++++ .../io/filechooser/FilePreviewProvider.java | 44 +++ .../io/filechooser/FileTypeFilter.java | 180 +++++++++ .../io/filechooser/OpenFileRequest.java | 127 +++++++ .../codename1/io/filechooser/SaveContent.java | 47 +++ .../io/filechooser/SaveFileRequest.java | 189 ++++++++++ .../io/filechooser/package-info.java | 34 ++ CodenameOne/src/com/codename1/ui/Display.java | 47 +++ .../codename1/impl/FileDialogSupportTest.java | 20 + .../io/filechooser/FileDialogRequestTest.java | 98 +++++ 16 files changed, 1855 insertions(+) create mode 100644 CodenameOne/src/com/codename1/impl/FileDialogSupport.java create mode 100644 CodenameOne/src/com/codename1/io/filechooser/FileDialogFeatures.java create mode 100644 CodenameOne/src/com/codename1/io/filechooser/FileDialogHints.java create mode 100644 CodenameOne/src/com/codename1/io/filechooser/FileDialogOption.java create mode 100644 CodenameOne/src/com/codename1/io/filechooser/FileDialogRequest.java create mode 100644 CodenameOne/src/com/codename1/io/filechooser/FileDialogResult.java create mode 100644 CodenameOne/src/com/codename1/io/filechooser/FilePreviewProvider.java create mode 100644 CodenameOne/src/com/codename1/io/filechooser/FileTypeFilter.java create mode 100644 CodenameOne/src/com/codename1/io/filechooser/OpenFileRequest.java create mode 100644 CodenameOne/src/com/codename1/io/filechooser/SaveContent.java create mode 100644 CodenameOne/src/com/codename1/io/filechooser/SaveFileRequest.java create mode 100644 CodenameOne/src/com/codename1/io/filechooser/package-info.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/impl/FileDialogSupportTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/io/filechooser/FileDialogRequestTest.java diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index 110d1da02dd..a100a32c5a0 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -44,6 +44,10 @@ import com.codename1.io.Preferences; import com.codename1.io.Storage; import com.codename1.io.Util; +import com.codename1.io.filechooser.FileDialogFeatures; +import com.codename1.io.filechooser.FileDialogResult; +import com.codename1.io.filechooser.OpenFileRequest; +import com.codename1.io.filechooser.SaveFileRequest; import com.codename1.io.bonjour.BonjourPlatform; import com.codename1.io.usb.UsbPlatform; import com.codename1.io.wifi.WifiDirectPlatform; @@ -8890,6 +8894,57 @@ public void openFileChooser(final ActionListener response, String accept) { } } + /// Shows an Open dialog. The default answers through `#openFileChooser`, so + /// a port with a native single-file picker already gets a native dialog; + /// ports override this to support the rest of the request. + /// + /// #### Parameters + /// + /// - `request`: the dialog description + /// - `callback`: receives the result on the EDT + public void showOpenDialog(OpenFileRequest request, SuccessCallback callback) { + FileDialogSupport.showOpen(this, request, callback); + } + + /// Shows a Save dialog. The default renders a Codename One form that saves + /// into the request's initial directory, or the app home. + /// + /// #### Parameters + /// + /// - `request`: the dialog description + /// - `callback`: receives the result on the EDT + public void showSaveDialog(SaveFileRequest request, SuccessCallback callback) { + FileDialogSupport.showSave(this, request, callback); + } + + /// Writes a save request's content to the `FileSystemStorage` path in the + /// result off the EDT, then calls back on the EDT. Ports call this once the + /// native dialog has returned a writable path. + /// + /// #### Parameters + /// + /// - `request`: the request + /// - `result`: the user's choice; its path is the destination + /// - `callback`: receives the result, with `FileDialogResult#getError()` set on failure + protected void completeSaveDialog(SaveFileRequest request, FileDialogResult result, + SuccessCallback callback) { + FileDialogSupport.completeSave(request, result, callback); + } + + /// Checks a `FileDialogFeatures` capability of this port's file dialogs. + /// + /// #### Parameters + /// + /// - `feature`: a `FileDialogFeatures` constant + /// + /// #### Returns + /// + /// true if supported + public boolean isFileDialogFeatureSupported(int feature) { + return feature == FileDialogFeatures.INITIAL_DIRECTORY + || feature == FileDialogFeatures.SAVE_NAME_EDITING; + } + /// Opens the device image gallery /// /// #### Parameters diff --git a/CodenameOne/src/com/codename1/impl/FileDialogSupport.java b/CodenameOne/src/com/codename1/impl/FileDialogSupport.java new file mode 100644 index 00000000000..f85e0b231ba --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/FileDialogSupport.java @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.impl; + +import com.codename1.io.FileSystemStorage; +import com.codename1.io.Log; +import com.codename1.io.Util; +import com.codename1.io.filechooser.FileDialogOption; +import com.codename1.io.filechooser.FileDialogResult; +import com.codename1.io.filechooser.FileTypeFilter; +import com.codename1.io.filechooser.OpenFileRequest; +import com.codename1.io.filechooser.SaveFileRequest; +import com.codename1.ui.Button; +import com.codename1.ui.CheckBox; +import com.codename1.ui.ComboBox; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.Display; +import com.codename1.ui.Label; +import com.codename1.ui.TextField; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.util.SuccessCallback; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.HashMap; +import java.util.Map; + +/// The Codename One rendered Open and Save dialogs used where a port has no +/// native one, and the save-completion step every port shares. +final class FileDialogSupport { + private FileDialogSupport() { + } + + /// Answers an open request through `CodenameOneImplementation#openFileChooser`, + /// which every port already implements, natively where it can. + static void showOpen(CodenameOneImplementation impl, final OpenFileRequest request, + final SuccessCallback callback) { + impl.openFileChooser(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + Object src = evt == null ? null : evt.getSource(); + if (src instanceof String) { + callback.onSucess(new FileDialogResult(request, new String[]{(String) src}, -1, null)); + } else { + callback.onSucess(FileDialogResult.cancelled(request)); + } + } + }, request.toAcceptString()); + } + + /// Shows the fallback save form: a name field, the filter list and the + /// request's options, saving into its initial directory or app home. + static void showSave(final CodenameOneImplementation impl, final SaveFileRequest request, + final SuccessCallback callback) { + final FileSystemStorage fs = FileSystemStorage.getInstance(); + String dir = request.getInitialDirectory(); + if (dir == null || !fs.isDirectory(dir)) { + dir = fs.getAppHomePath(); + } + final String directory = dir; + final Dialog d = new Dialog(request.getTitle() == null ? "Save" : request.getTitle()); + d.setLayout(new BorderLayout()); + Container body = new Container(BoxLayout.y()); + if (request.getMessage() != null) { + body.add(new Label(request.getMessage())); + } + String name = request.getSuggestedName(); + final TextField nameField = new TextField(name == null ? "" : name); + body.add(new Label("Name")).add(nameField); + final FileTypeFilter[] filters = request.getFilters(); + final ComboBox filterBox = new ComboBox(); + if (filters.length > 1) { + for (FileTypeFilter f : filters) { + filterBox.addItem(f.getLabel()); + } + filterBox.setSelectedIndex(request.getSelectedFilter()); + body.add(new Label("Format")).add(filterBox); + } + final FileDialogOption[] options = request.getOptions(); + final Object[] editors = new Object[options.length]; + for (int i = 0; i < options.length; i++) { + FileDialogOption o = options[i]; + if (o.getType() == FileDialogOption.TYPE_CHECKBOX) { + CheckBox cb = new CheckBox(o.getLabel()); + cb.setSelected("true".equals(o.getDefaultValue())); + editors[i] = cb; + body.add(cb); + } else if (o.getType() == FileDialogOption.TYPE_CHOICE) { + ComboBox box = new ComboBox((Object[]) o.getChoices()); + box.setSelectedItem(o.getDefaultValue()); + editors[i] = box; + body.add(new Label(o.getLabel())).add(box); + } else { + TextField tf = new TextField(o.getDefaultValue() == null ? "" : o.getDefaultValue()); + editors[i] = tf; + body.add(new Label(o.getLabel())).add(tf); + } + } + if (request.getAccessory() != null) { + body.add(request.getAccessory()); + } + d.add(BorderLayout.CENTER, body); + Button cancel = new Button("Cancel"); + Button save = new Button(request.getAcceptLabel() == null ? "Save" : request.getAcceptLabel()); + d.add(BorderLayout.SOUTH, FlowLayout.encloseRight(cancel, save)); + final boolean[] done = new boolean[1]; + cancel.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + d.dispose(); + } + }); + save.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + String n = nameField.getText().trim(); + if (n.length() == 0 || n.indexOf('/') > -1 || n.indexOf('\\') > -1) { + return; + } + int filter = filters.length > 1 ? filterBox.getSelectedIndex() : request.getSelectedFilter(); + n = withExtension(n, filter < 0 ? null : filters[filter]); + String path = directory.endsWith("/") ? directory + n : directory + "/" + n; + if (request.isConfirmOverwrite() && fs.exists(path) + && !Dialog.show("Replace", n + " already exists. Replace it?", "Replace", "Cancel")) { + return; + } + Map values = new HashMap(); + for (int i = 0; i < options.length; i++) { + values.put(options[i].getId(), editorValue(editors[i])); + } + done[0] = true; + d.dispose(); + completeSave(request, new FileDialogResult(request, new String[]{path}, filter, values), callback); + } + }); + d.showPacked(BorderLayout.CENTER, true); + if (!done[0]) { + callback.onSucess(FileDialogResult.cancelled(request)); + } + } + + private static String editorValue(Object editor) { + if (editor instanceof CheckBox) { + return ((CheckBox) editor).isSelected() ? "true" : "false"; + } + if (editor instanceof ComboBox) { + Object o = ((ComboBox) editor).getSelectedItem(); + return o == null ? null : o.toString(); + } + if (editor instanceof TextField) { + return ((TextField) editor).getText(); + } + return null; + } + + /// Appends the filter's first extension unless the name already carries one of its extensions. + static String withExtension(String name, FileTypeFilter filter) { + if (filter == null || filter.acceptsName(name)) { + return name; + } + String[] ext = filter.getExtensions(); + return ext.length == 0 ? name : name + "." + ext[0]; + } + + /// Writes the request's content to the result's `FileSystemStorage` path off + /// the EDT, then delivers the result on the EDT. + static void completeSave(final SaveFileRequest request, final FileDialogResult result, + final SuccessCallback callback) { + Display.getInstance().scheduleBackgroundTask(new Runnable() { + @Override + public void run() { + OutputStream out = null; + try { + out = FileSystemStorage.getInstance().openOutputStream(result.getPath()); + request.writeContent(result, out); + } catch (IOException err) { + Log.e(err); + result.setError(err); + } finally { + Util.cleanup(out); + } + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + callback.onSucess(result); + } + }); + } + }); + } +} diff --git a/CodenameOne/src/com/codename1/io/filechooser/FileDialogFeatures.java b/CodenameOne/src/com/codename1/io/filechooser/FileDialogFeatures.java new file mode 100644 index 00000000000..65117c988a3 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/filechooser/FileDialogFeatures.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.io.filechooser; + +/// Capabilities a platform's file dialogs may have, queried with +/// `com.codename1.ui.Display#isFileDialogFeatureSupported(int)`. +/// +/// Every platform can open and save a single file, at worst through the +/// Codename One fallback chooser; the features below are the ones that vary. +public final class FileDialogFeatures { + /// The dialog is the platform's own, not the Codename One fallback. + public static final int NATIVE = 1; + /// `OpenFileRequest#multiSelect(boolean)` returns several files. + public static final int MULTI_SELECT = 2; + /// `OpenFileRequest#directories(boolean)` picks folders. + public static final int DIRECTORIES = 3; + /// More than one `FileTypeFilter` is shown as a user-selectable list. + public static final int FILTER_LIST = 4; + /// `FileDialogOption` controls are shown. + public static final int OPTIONS = 5; + /// `FileDialogRequest#accessory(com.codename1.ui.Component)` is shown. + public static final int ACCESSORY_COMPONENT = 6; + /// `OpenFileRequest#preview(FilePreviewProvider)` is called as the + /// selection changes. + public static final int PREVIEW = 7; + /// `FileDialogRequest#initialDirectory(String)` is honoured. + public static final int INITIAL_DIRECTORY = 8; + /// The save dialog lets the user edit the suggested name. + public static final int SAVE_NAME_EDITING = 9; + + private FileDialogFeatures() { + } +} diff --git a/CodenameOne/src/com/codename1/io/filechooser/FileDialogHints.java b/CodenameOne/src/com/codename1/io/filechooser/FileDialogHints.java new file mode 100644 index 00000000000..2e051528fc6 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/filechooser/FileDialogHints.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.io.filechooser; + +/// Platform specific hints for `FileDialogRequest#hint(String, Object)`. +/// +/// A hint tunes a native dialog in a way only some platforms can. A platform +/// that does not know a hint ignores it, so an application can set every hint +/// it cares about unconditionally. The prefix names the platform that reads it. +public final class FileDialogHints { + /// `Boolean`: show the Finder tag field in a macOS save panel. + public static final String MAC_SHOWS_TAG_FIELD = "mac.showsTagField"; + /// `Boolean`: allow creating folders from a macOS panel. Defaults to true. + public static final String MAC_CAN_CREATE_DIRECTORIES = "mac.canCreateDirectories"; + /// `Boolean`: show hidden files in a macOS panel. + public static final String MAC_SHOWS_HIDDEN_FILES = "mac.showsHiddenFiles"; + /// `Boolean`: let the user hide the extension in a macOS save panel. + public static final String MAC_CAN_SELECT_HIDDEN_EXTENSION = "mac.canSelectHiddenExtension"; + /// `Boolean`: treat packages (`.app`, `.bundle`) as folders in a macOS open panel. + public static final String MAC_TREATS_PACKAGES_AS_DIRECTORIES = "mac.treatsFilePackagesAsDirectories"; + /// `Boolean`: show file extensions in the iOS document picker. + public static final String IOS_SHOWS_FILE_EXTENSIONS = "ios.showsFileExtensions"; + /// `Boolean`: move rather than copy the exported file on iOS. Defaults to copy. + public static final String IOS_EXPORT_AS_MOVE = "ios.exportAsMove"; + /// `String`: a `content://` URI the Android picker starts in. + public static final String ANDROID_INITIAL_URI = "android.initialUri"; + /// `Boolean`: keep persistable read/write permission to the picked + /// Android document across restarts. + public static final String ANDROID_PERSIST_PERMISSION = "android.persistPermission"; + /// `String`: a GUID string; Windows remembers the last folder and size + /// separately per GUID, so different dialogs of one app do not share state. + public static final String WIN_CLIENT_GUID = "win.clientGuid"; + /// `String`: the folder Windows shows when the user has no recent folder, + /// as opposed to `FileDialogRequest#initialDirectory`, which always wins. + public static final String WIN_DEFAULT_FOLDER = "win.defaultFolder"; + /// `String`: an id under which the browser remembers the last directory + /// (`showSaveFilePicker`'s `id`). + public static final String WEB_ID = "web.id"; + /// `String`: a well-known browser directory to start in: `desktop`, + /// `documents`, `downloads`, `music`, `pictures` or `videos`. + public static final String WEB_START_IN = "web.startIn"; + /// `Boolean`: show hidden files in the GTK chooser and the simulator. + public static final String DESKTOP_SHOW_HIDDEN = "desktop.showHidden"; + + private FileDialogHints() { + } +} diff --git a/CodenameOne/src/com/codename1/io/filechooser/FileDialogOption.java b/CodenameOne/src/com/codename1/io/filechooser/FileDialogOption.java new file mode 100644 index 00000000000..570a9d98313 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/filechooser/FileDialogOption.java @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.io.filechooser; + +import java.util.ArrayList; +import java.util.List; + +/// A declarative control added to a native file dialog: a checkbox, a choice +/// list or a text field. +/// +/// This is the portable way to customize a dialog. Each desktop maps it to its +/// own widgets (an accessory view on macOS, `IFileDialogCustomize` on Windows, +/// `gtk_file_chooser_add_choice` on Linux, a Swing accessory in the simulator), +/// and the fallback chooser renders it with Codename One components. Mobile +/// pickers have a fixed UI and ignore options; check +/// `FileDialogFeatures#OPTIONS` before relying on them. The value the user +/// chose is read back from `FileDialogResult#getOptionValue(String)`. +public final class FileDialogOption { + /// A checkbox. Its value is `"true"` or `"false"`. + public static final int TYPE_CHECKBOX = 1; + /// A choice list. Its value is the selected choice. + public static final int TYPE_CHOICE = 2; + /// A free text field. + public static final int TYPE_TEXT = 3; + + private final String id; + private final int type; + private final String label; + private final List choices = new ArrayList(); + private String defaultValue; + + private FileDialogOption(String id, int type, String label, String defaultValue) { + this.id = id; + this.type = type; + this.label = label; + this.defaultValue = defaultValue; + } + + /// Creates a checkbox option. + /// + /// #### Parameters + /// + /// - `id`: the key its value is reported under + /// - `label`: the checkbox text + /// - `checked`: the initial state + /// + /// #### Returns + /// + /// the option + public static FileDialogOption checkbox(String id, String label, boolean checked) { + return new FileDialogOption(id, TYPE_CHECKBOX, label, checked ? "true" : "false"); + } + + /// Creates a choice list option. The first choice is selected initially. + /// + /// #### Parameters + /// + /// - `id`: the key its value is reported under + /// - `label`: the label shown beside the list + /// - `choices`: the entries + /// + /// #### Returns + /// + /// the option + public static FileDialogOption choice(String id, String label, String... choices) { + FileDialogOption o = new FileDialogOption(id, TYPE_CHOICE, label, null); + for (String c : choices) { + o.choices.add(c); + } + if (!o.choices.isEmpty()) { + o.defaultValue = o.choices.get(0); + } + return o; + } + + /// Creates a text field option. + /// + /// #### Parameters + /// + /// - `id`: the key its value is reported under + /// - `label`: the label shown beside the field + /// - `value`: the initial text + /// + /// #### Returns + /// + /// the option + public static FileDialogOption text(String id, String label, String value) { + return new FileDialogOption(id, TYPE_TEXT, label, value); + } + + /// Changes the initial value; for a choice it must be one of the choices. + /// + /// #### Parameters + /// + /// - `value`: the initial value + /// + /// #### Returns + /// + /// this option + public FileDialogOption defaultValue(String value) { + this.defaultValue = value; + return this; + } + + /// The key the value is reported under. + /// + /// #### Returns + /// + /// the id + public String getId() { + return id; + } + + /// One of `#TYPE_CHECKBOX`, `#TYPE_CHOICE` or `#TYPE_TEXT`. + /// + /// #### Returns + /// + /// the type + public int getType() { + return type; + } + + /// The text shown for the control. + /// + /// #### Returns + /// + /// the label + public String getLabel() { + return label; + } + + /// The entries of a choice option, empty for other types. + /// + /// #### Returns + /// + /// a copy of the choices + public String[] getChoices() { + return choices.toArray(new String[choices.size()]); + } + + /// The initial value. + /// + /// #### Returns + /// + /// the initial value + public String getDefaultValue() { + return defaultValue; + } +} diff --git a/CodenameOne/src/com/codename1/io/filechooser/FileDialogRequest.java b/CodenameOne/src/com/codename1/io/filechooser/FileDialogRequest.java new file mode 100644 index 00000000000..1e89ac3074e --- /dev/null +++ b/CodenameOne/src/com/codename1/io/filechooser/FileDialogRequest.java @@ -0,0 +1,349 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.io.filechooser; + +import com.codename1.ui.Component; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The settings shared by `OpenFileRequest` and `SaveFileRequest`. +/// +/// Every setter returns the request, so a dialog is described in one chain. +/// Settings a platform cannot honour are ignored rather than failing, and +/// `com.codename1.ui.Display#isFileDialogFeatureSupported(int)` says up front +/// which ones apply. Platform specific tuning goes through `#hint(String, Object)`. +/// +/// #### Parameters +/// +/// - ``: the concrete request type, so chained setters keep it +public abstract class FileDialogRequest> { + private String title; + private String message; + private String acceptLabel; + private String initialDirectory; + private final List filters = new ArrayList(); + private int selectedFilter; + private final Map hints = new HashMap(); + private final List options = new ArrayList(); + private Component accessory; + + FileDialogRequest() { + } + + @SuppressWarnings("unchecked") + private T self() { + return (T) this; + } + + /// Sets the window title. Ignored by the mobile pickers. + /// + /// #### Parameters + /// + /// - `title`: the title + /// + /// #### Returns + /// + /// this request + public T title(String title) { + this.title = title; + return self(); + } + + /// Sets an explanatory line shown in the dialog (`NSSavePanel.message` on + /// macOS, the fallback dialog's header elsewhere). + /// + /// #### Parameters + /// + /// - `message`: the text + /// + /// #### Returns + /// + /// this request + public T message(String message) { + this.message = message; + return self(); + } + + /// Replaces the text of the Open or Save button on desktops. + /// + /// #### Parameters + /// + /// - `label`: the button text + /// + /// #### Returns + /// + /// this request + public T acceptLabel(String label) { + this.acceptLabel = label; + return self(); + } + + /// Sets the folder the dialog starts in, as a `FileSystemStorage` path. + /// + /// #### Parameters + /// + /// - `path`: the folder + /// + /// #### Returns + /// + /// this request + public T initialDirectory(String path) { + this.initialDirectory = path; + return self(); + } + + /// Adds a file type filter. The first one added is selected unless + /// `#selectedFilter(int)` says otherwise. + /// + /// #### Parameters + /// + /// - `filter`: the filter + /// + /// #### Returns + /// + /// this request + public T addFilter(FileTypeFilter filter) { + filters.add(filter); + return self(); + } + + /// Selects the filter shown initially. + /// + /// #### Parameters + /// + /// - `index`: an index into the filters added so far + /// + /// #### Returns + /// + /// this request + public T selectedFilter(int index) { + this.selectedFilter = index; + return self(); + } + + /// Sets a platform specific hint; see `FileDialogHints`. Unknown hints are + /// ignored. + /// + /// #### Parameters + /// + /// - `key`: the hint name + /// - `value`: its value + /// + /// #### Returns + /// + /// this request + public T hint(String key, Object value) { + hints.put(key, value); + return self(); + } + + /// Adds a portable control to the dialog; see `FileDialogOption`. + /// + /// #### Parameters + /// + /// - `option`: the control + /// + /// #### Returns + /// + /// this request + public T addOption(FileDialogOption option) { + options.add(option); + return self(); + } + + /// Embeds a Codename One component in the dialog, such as a format and + /// quality panel for an export. Only shown where + /// `FileDialogFeatures#ACCESSORY_COMPONENT` is supported (macOS and the + /// desktop/simulator port); use `#addOption(FileDialogOption)` for + /// controls that must appear on every desktop. The component is still + /// reachable from the callback, so its state can be read after the dialog + /// closes. + /// + /// #### Parameters + /// + /// - `accessory`: the component + /// + /// #### Returns + /// + /// this request + public T accessory(Component accessory) { + this.accessory = accessory; + return self(); + } + + /// The window title, or null. + /// + /// #### Returns + /// + /// the title + public String getTitle() { + return title; + } + + /// The explanatory message, or null. + /// + /// #### Returns + /// + /// the message + public String getMessage() { + return message; + } + + /// The accept button text, or null for the platform default. + /// + /// #### Returns + /// + /// the label + public String getAcceptLabel() { + return acceptLabel; + } + + /// The starting folder, or null. + /// + /// #### Returns + /// + /// the folder path + public String getInitialDirectory() { + return initialDirectory; + } + + /// The filters in the order they were added. + /// + /// #### Returns + /// + /// a copy of the filters + public FileTypeFilter[] getFilters() { + return filters.toArray(new FileTypeFilter[filters.size()]); + } + + /// The initially selected filter index, clamped to the filters present. + /// + /// #### Returns + /// + /// the index, or -1 when there are no filters + public int getSelectedFilter() { + if (filters.isEmpty()) { + return -1; + } + if (selectedFilter < 0 || selectedFilter >= filters.size()) { + return 0; + } + return selectedFilter; + } + + /// Reads a hint. + /// + /// #### Parameters + /// + /// - `key`: the hint name + /// + /// #### Returns + /// + /// the value, or null + public Object getHint(String key) { + return hints.get(key); + } + + /// Reads a boolean hint. + /// + /// #### Parameters + /// + /// - `key`: the hint name + /// - `def`: the value when the hint is absent or not a boolean + /// + /// #### Returns + /// + /// the value + public boolean getBooleanHint(String key, boolean def) { + Object o = hints.get(key); + if (o instanceof Boolean) { + return ((Boolean) o).booleanValue(); + } + if (o instanceof String) { + return "true".equalsIgnoreCase((String) o); + } + return def; + } + + /// Reads a string hint. + /// + /// #### Parameters + /// + /// - `key`: the hint name + /// + /// #### Returns + /// + /// the value as a string, or null + public String getStringHint(String key) { + Object o = hints.get(key); + return o == null ? null : o.toString(); + } + + /// The portable controls in the order they were added. + /// + /// #### Returns + /// + /// a copy of the options + public FileDialogOption[] getOptions() { + return options.toArray(new FileDialogOption[options.size()]); + } + + /// The accessory component, or null. + /// + /// #### Returns + /// + /// the accessory + public Component getAccessory() { + return accessory; + } + + /// Joins every filter's extensions and MIME types into the comma separated + /// form `com.codename1.ui.Display#openFileChooser` takes. + /// + /// #### Returns + /// + /// the accept string, or null when nothing is filtered + public String toAcceptString() { + StringBuilder sb = new StringBuilder(); + for (FileTypeFilter f : filters) { + for (String e : f.getExtensions()) { + append(sb, e); + } + for (String m : f.getMimeTypes()) { + append(sb, m); + } + } + return sb.length() == 0 ? null : sb.toString(); + } + + private static void append(StringBuilder sb, String s) { + if (sb.length() > 0) { + sb.append(','); + } + sb.append(s); + } +} diff --git a/CodenameOne/src/com/codename1/io/filechooser/FileDialogResult.java b/CodenameOne/src/com/codename1/io/filechooser/FileDialogResult.java new file mode 100644 index 00000000000..bca95951fd6 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/filechooser/FileDialogResult.java @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.io.filechooser; + +import java.util.HashMap; +import java.util.Map; + +/// The outcome of a file dialog, delivered to the callback on the EDT. +public final class FileDialogResult { + private final String[] paths; + private final int selectedFilter; + private final FileTypeFilter filter; + private final Map optionValues; + private Throwable error; + + /// Creates a result; ports and the fallback dialogs build these. + /// + /// #### Parameters + /// + /// - `request`: the request being answered + /// - `paths`: the chosen paths, or null/empty when cancelled + /// - `selectedFilter`: the filter the user ended on, or -1 + /// - `optionValues`: option id to value, or null for the defaults + public FileDialogResult(FileDialogRequest request, String[] paths, int selectedFilter, + Map optionValues) { + this.paths = paths == null ? new String[0] : paths; + FileTypeFilter[] filters = request.getFilters(); + if (selectedFilter < 0 || selectedFilter >= filters.length) { + selectedFilter = request.getSelectedFilter(); + } + this.selectedFilter = selectedFilter; + this.filter = selectedFilter < 0 ? null : filters[selectedFilter]; + Map values = new HashMap(); + for (FileDialogOption o : request.getOptions()) { + values.put(o.getId(), o.getDefaultValue()); + } + if (optionValues != null) { + values.putAll(optionValues); + } + this.optionValues = values; + } + + /// Creates the result of a dialog the user dismissed. + /// + /// #### Parameters + /// + /// - `request`: the request being answered + /// + /// #### Returns + /// + /// a cancelled result + public static FileDialogResult cancelled(FileDialogRequest request) { + return new FileDialogResult(request, null, -1, null); + } + + /// True when the user dismissed the dialog, or a save failed before a file existed. + /// + /// #### Returns + /// + /// whether nothing was chosen + public boolean isCancelled() { + return paths.length == 0; + } + + /// The chosen files. For a save, the one file written. + /// + /// #### Returns + /// + /// a copy of the paths; empty when cancelled + public String[] getPaths() { + String[] out = new String[paths.length]; + System.arraycopy(paths, 0, out, 0, paths.length); + return out; + } + + /// The first chosen file. + /// + /// #### Returns + /// + /// the path, or null when cancelled + public String getPath() { + return paths.length == 0 ? null : paths[0]; + } + + /// The index of the filter the user ended on. + /// + /// #### Returns + /// + /// the index, or -1 without filters + public int getSelectedFilterIndex() { + return selectedFilter; + } + + /// The filter the user ended on; for a save this is the chosen format. + /// + /// #### Returns + /// + /// the filter, or null without filters + public FileTypeFilter getSelectedFilter() { + return filter; + } + + /// The value of a `FileDialogOption`, or its default where the platform + /// shows no options. + /// + /// #### Parameters + /// + /// - `id`: the option id + /// + /// #### Returns + /// + /// the value + public String getOptionValue(String id) { + return optionValues.get(id); + } + + /// The failure of a save, or null. A failed save still reports the path it + /// tried, so the partial file can be cleaned up. + /// + /// #### Returns + /// + /// the error + public Throwable getError() { + return error; + } + + /// Records a save failure; used by ports. + /// + /// #### Parameters + /// + /// - `error`: the failure + public void setError(Throwable error) { + this.error = error; + } +} diff --git a/CodenameOne/src/com/codename1/io/filechooser/FilePreviewProvider.java b/CodenameOne/src/com/codename1/io/filechooser/FilePreviewProvider.java new file mode 100644 index 00000000000..eac454af264 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/filechooser/FilePreviewProvider.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.io.filechooser; + +import com.codename1.ui.Component; + +/// Supplies the preview an open dialog shows beside its file list. +/// +/// Called on the EDT each time the highlighted file changes, on platforms that +/// report `FileDialogFeatures#PREVIEW`. Platforms with a built-in preview +/// (iOS, Android) show their own and never call it. +public interface FilePreviewProvider { + /// Builds the preview of a file. + /// + /// #### Parameters + /// + /// - `path`: the highlighted file, readable with `com.codename1.io.FileSystemStorage` + /// + /// #### Returns + /// + /// the component to show, or `null` to show nothing + Component createPreview(String path); +} diff --git a/CodenameOne/src/com/codename1/io/filechooser/FileTypeFilter.java b/CodenameOne/src/com/codename1/io/filechooser/FileTypeFilter.java new file mode 100644 index 00000000000..aeb1ac09672 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/filechooser/FileTypeFilter.java @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.io.filechooser; + +import java.util.ArrayList; +import java.util.List; + +/// A named group of file types a file dialog offers, such as "PNG Image" or +/// "Text Documents". +/// +/// A filter can name the same types three ways, and each platform reads the +/// one it understands: desktop dialogs and the fallback chooser use the +/// extensions, Android and the browser use the MIME types, and iOS and macOS +/// use the Uniform Type Identifiers when given, deriving them from the +/// extensions otherwise. Supplying extensions alone works everywhere. +/// +/// ```java +/// FileTypeFilter png = new FileTypeFilter("PNG Image") +/// .extensions("png").mimeTypes("image/png").utTypes("public.png"); +/// ``` +public class FileTypeFilter { + private final String label; + private final List extensions = new ArrayList(); + private final List mimeTypes = new ArrayList(); + private final List utTypes = new ArrayList(); + + /// Creates a filter with the label the dialog shows for it. + /// + /// #### Parameters + /// + /// - `label`: the human readable name of the filter + public FileTypeFilter(String label) { + this.label = label; + } + + /// Adds file extensions, with or without the leading dot. + /// + /// #### Parameters + /// + /// - `ext`: extensions such as `"png"` or `".jpg"` + /// + /// #### Returns + /// + /// this filter + public FileTypeFilter extensions(String... ext) { + for (String e : ext) { + if (e != null) { + e = e.trim(); + if (e.startsWith(".")) { + e = e.substring(1); + } + if (e.length() > 0) { + extensions.add(e); + } + } + } + return this; + } + + /// Adds MIME types such as `"image/png"` or `"image/*"`. + /// + /// #### Parameters + /// + /// - `types`: the MIME types + /// + /// #### Returns + /// + /// this filter + public FileTypeFilter mimeTypes(String... types) { + for (String t : types) { + if (t != null && t.trim().length() > 0) { + mimeTypes.add(t.trim()); + } + } + return this; + } + + /// Adds Apple Uniform Type Identifiers such as `"public.png"`. Ignored + /// outside iOS and macOS. + /// + /// #### Parameters + /// + /// - `types`: the type identifiers + /// + /// #### Returns + /// + /// this filter + public FileTypeFilter utTypes(String... types) { + for (String t : types) { + if (t != null && t.trim().length() > 0) { + utTypes.add(t.trim()); + } + } + return this; + } + + /// The label the dialog shows. + /// + /// #### Returns + /// + /// the label + public String getLabel() { + return label; + } + + /// The extensions, without leading dots. + /// + /// #### Returns + /// + /// a copy of the extension list + public String[] getExtensions() { + return extensions.toArray(new String[extensions.size()]); + } + + /// The MIME types. + /// + /// #### Returns + /// + /// a copy of the MIME type list + public String[] getMimeTypes() { + return mimeTypes.toArray(new String[mimeTypes.size()]); + } + + /// The Uniform Type Identifiers. + /// + /// #### Returns + /// + /// a copy of the identifier list + public String[] getUtTypes() { + return utTypes.toArray(new String[utTypes.size()]); + } + + /// Checks whether a file name ends with one of this filter's extensions. + /// A filter without extensions accepts every name. + /// + /// #### Parameters + /// + /// - `name`: the file name or path + /// + /// #### Returns + /// + /// true if the name matches + public boolean acceptsName(String name) { + if (extensions.isEmpty()) { + return true; + } + if (name == null) { + return false; + } + for (String e : extensions) { + int start = name.length() - e.length() - 1; + if (start >= 0 && name.charAt(start) == '.' + && name.regionMatches(true, start + 1, e, 0, e.length())) { + return true; + } + } + return false; + } +} diff --git a/CodenameOne/src/com/codename1/io/filechooser/OpenFileRequest.java b/CodenameOne/src/com/codename1/io/filechooser/OpenFileRequest.java new file mode 100644 index 00000000000..0885e380513 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/filechooser/OpenFileRequest.java @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.io.filechooser; + +/// Describes a native Open dialog for +/// `com.codename1.ui.Display#showOpenDialog(OpenFileRequest, com.codename1.util.SuccessCallback)`. +/// +/// ```java +/// Display.getInstance().showOpenDialog(new OpenFileRequest() +/// .title("Import photos") +/// .multiSelect(true) +/// .addFilter(new FileTypeFilter("Images").extensions("png", "jpg").mimeTypes("image/*")) +/// .preview(path -> new ScaleImageLabel(loadThumbnail(path))), +/// result -> { +/// if (!result.isCancelled()) { +/// for (String p : result.getPaths()) { +/// importFile(p); +/// } +/// } +/// }); +/// ``` +/// +/// The paths reported are readable with `com.codename1.io.FileSystemStorage`. +/// On Android and iOS the picked document is copied into the app's cache first, +/// since those platforms hand out content that outlives no process. +public class OpenFileRequest extends FileDialogRequest { + private boolean multiSelect; + private boolean directories; + private FilePreviewProvider preview; + + /// Creates an Open request with no filter, accepting every file. + public OpenFileRequest() { + } + + /// Lets the user pick several files. Platforms without + /// `FileDialogFeatures#MULTI_SELECT` return one. + /// + /// #### Parameters + /// + /// - `multiSelect`: true to allow several files + /// + /// #### Returns + /// + /// this request + public OpenFileRequest multiSelect(boolean multiSelect) { + this.multiSelect = multiSelect; + return this; + } + + /// Picks folders instead of files, where `FileDialogFeatures#DIRECTORIES` + /// is supported. + /// + /// #### Parameters + /// + /// - `directories`: true to pick folders + /// + /// #### Returns + /// + /// this request + public OpenFileRequest directories(boolean directories) { + this.directories = directories; + return this; + } + + /// Supplies a preview shown beside the file list as the selection changes, + /// where `FileDialogFeatures#PREVIEW` is supported. + /// + /// #### Parameters + /// + /// - `preview`: the preview provider + /// + /// #### Returns + /// + /// this request + public OpenFileRequest preview(FilePreviewProvider preview) { + this.preview = preview; + return this; + } + + /// Whether several files may be picked. + /// + /// #### Returns + /// + /// true for multi selection + public boolean isMultiSelect() { + return multiSelect; + } + + /// Whether folders are picked. + /// + /// #### Returns + /// + /// true for folders + public boolean isDirectories() { + return directories; + } + + /// The preview provider, or null. + /// + /// #### Returns + /// + /// the provider + public FilePreviewProvider getPreview() { + return preview; + } +} diff --git a/CodenameOne/src/com/codename1/io/filechooser/SaveContent.java b/CodenameOne/src/com/codename1/io/filechooser/SaveContent.java new file mode 100644 index 00000000000..be822c42636 --- /dev/null +++ b/CodenameOne/src/com/codename1/io/filechooser/SaveContent.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.io.filechooser; + +import java.io.IOException; +import java.io.OutputStream; + +/// Writes the content of a file once the user has chosen where to save it. +/// +/// Called off the EDT, so a large document can be serialized without freezing +/// the UI. The stream is closed by the caller. `SaveFileRequest#onFilterChanged` +/// and `FileDialogResult#getOptionValue(String)` tell it which format and +/// options the user picked. +public interface SaveContent { + /// Writes the file. + /// + /// #### Parameters + /// + /// - `result`: what the user chose (location, filter, options) + /// - `out`: the destination + /// + /// #### Throws + /// + /// - `IOException`: if writing fails; the save is reported as failed + void write(FileDialogResult result, OutputStream out) throws IOException; +} diff --git a/CodenameOne/src/com/codename1/io/filechooser/SaveFileRequest.java b/CodenameOne/src/com/codename1/io/filechooser/SaveFileRequest.java new file mode 100644 index 00000000000..61e1ce3886d --- /dev/null +++ b/CodenameOne/src/com/codename1/io/filechooser/SaveFileRequest.java @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.io.filechooser; + +import com.codename1.io.FileSystemStorage; +import com.codename1.io.Util; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/// Describes a native Save dialog for +/// `com.codename1.ui.Display#showSaveDialog(SaveFileRequest, com.codename1.util.SuccessCallback)`. +/// +/// The content is supplied either as an existing file (`#sourceFile(String)`) +/// or written on demand (`#content(SaveContent)`), after the user has chosen +/// a location, format and options: +/// +/// ```java +/// Display.getInstance().showSaveDialog(new SaveFileRequest() +/// .suggestedName("Report.pdf") +/// .addFilter(new FileTypeFilter("PDF").extensions("pdf").mimeTypes("application/pdf")) +/// .addFilter(new FileTypeFilter("Plain text").extensions("txt").mimeTypes("text/plain")) +/// .addOption(FileDialogOption.checkbox("charts", "Include charts", true)) +/// .content((result, out) -> report.export(result.getSelectedFilter(), +/// "true".equals(result.getOptionValue("charts")), out)), +/// result -> { +/// if (result.getError() != null) { +/// ToastBar.showErrorMessage("Export failed"); +/// } +/// }); +/// ``` +/// +/// On Android the user picks a document through the Storage Access Framework +/// and the content is streamed into it; on iOS the content is written to a +/// temporary file which the document picker then exports. Either way the +/// application never needs a storage permission. +public class SaveFileRequest extends FileDialogRequest { + private String suggestedName; + private boolean confirmOverwrite = true; + private String sourceFile; + private SaveContent content; + + /// Creates a Save request. + public SaveFileRequest() { + } + + /// Sets the file name the dialog suggests, including its extension. + /// + /// #### Parameters + /// + /// - `name`: the file name + /// + /// #### Returns + /// + /// this request + public SaveFileRequest suggestedName(String name) { + this.suggestedName = name; + return this; + } + + /// Asks before replacing an existing file. On by default; the mobile + /// pickers always handle conflicts their own way. + /// + /// #### Parameters + /// + /// - `confirm`: false to replace silently + /// + /// #### Returns + /// + /// this request + public SaveFileRequest confirmOverwrite(boolean confirm) { + this.confirmOverwrite = confirm; + return this; + } + + /// Saves a copy of an existing `FileSystemStorage` file. + /// + /// #### Parameters + /// + /// - `path`: the file to copy + /// + /// #### Returns + /// + /// this request + public SaveFileRequest sourceFile(String path) { + this.sourceFile = path; + return this; + } + + /// Writes the content once the destination is known. Takes precedence + /// over `#sourceFile(String)`. + /// + /// #### Parameters + /// + /// - `content`: the writer + /// + /// #### Returns + /// + /// this request + public SaveFileRequest content(SaveContent content) { + this.content = content; + return this; + } + + /// The suggested file name, or null. + /// + /// #### Returns + /// + /// the name + public String getSuggestedName() { + return suggestedName; + } + + /// Whether replacing a file asks first. + /// + /// #### Returns + /// + /// true to confirm + public boolean isConfirmOverwrite() { + return confirmOverwrite; + } + + /// The file being copied, or null. + /// + /// #### Returns + /// + /// the path + public String getSourceFile() { + return sourceFile; + } + + /// The content writer, or null. + /// + /// #### Returns + /// + /// the writer + public SaveContent getContent() { + return content; + } + + /// Writes this request's content to a stream. Ports call it once the user + /// has chosen a destination; the stream is not closed here. + /// + /// #### Parameters + /// + /// - `result`: what the user chose + /// - `out`: the destination + /// + /// #### Throws + /// + /// - `IOException`: if neither content nor a source file was set, or writing fails + public void writeContent(FileDialogResult result, OutputStream out) throws IOException { + if (content != null) { + content.write(result, out); + return; + } + if (sourceFile == null) { + throw new IOException("SaveFileRequest has neither content nor a source file"); + } + InputStream in = FileSystemStorage.getInstance().openInputStream(sourceFile); + try { + Util.copyNoClose(in, out, 8192); + } finally { + Util.cleanup(in); + } + } +} diff --git a/CodenameOne/src/com/codename1/io/filechooser/package-info.java b/CodenameOne/src/com/codename1/io/filechooser/package-info.java new file mode 100644 index 00000000000..4ad0c9d2d7e --- /dev/null +++ b/CodenameOne/src/com/codename1/io/filechooser/package-info.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +/// Native Open and Save dialogs with portable filters and options, and +/// platform specific hints and callbacks. +/// +/// `OpenFileRequest` and `SaveFileRequest` describe a dialog, +/// `com.codename1.ui.Display#showOpenDialog` and +/// `com.codename1.ui.Display#showSaveDialog` show it, and `FileDialogResult` +/// reports what the user chose. Every platform can open and save a file; the +/// richer parts -- several filters, extra controls, embedded components, live +/// previews -- apply where the platform has them and are ignored elsewhere, as +/// `FileDialogFeatures` reports. +package com.codename1.io.filechooser; diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index fa76ae7c1bb..98096ee03a3 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -34,6 +34,9 @@ import com.codename1.impl.CodenameOneThread; import com.codename1.impl.ImplementationFactory; import com.codename1.impl.VirtualKeyboardInterface; +import com.codename1.io.filechooser.FileDialogResult; +import com.codename1.io.filechooser.OpenFileRequest; +import com.codename1.io.filechooser.SaveFileRequest; import com.codename1.io.Log; import com.codename1.io.Preferences; import com.codename1.io.Util; @@ -6946,6 +6949,50 @@ public void openFileChooser(ActionListener response, String accept) { impl.openFileChooser(response, accept); } + /// Shows the platform's Open dialog. + /// + /// Unlike `#openFileChooser(ActionListener, String)` this takes several + /// named filters, multiple selection, folder picking, extra controls, a + /// live preview and platform hints; each applies where the platform has it + /// (see `#isFileDialogFeatureSupported(int)`) and is ignored elsewhere. + /// + /// #### Parameters + /// + /// - `request`: the dialog description + /// - `callback`: receives the result on the EDT, also when the user cancels + public void showOpenDialog(OpenFileRequest request, SuccessCallback callback) { + impl.showOpenDialog(request, callback); + } + + /// Shows the platform's Save dialog and writes the request's content to + /// the location the user picks. + /// + /// Android saves through the Storage Access Framework and iOS through the + /// document picker, so no storage permission is needed; desktops show + /// their native save panel; other platforms show a Codename One form. + /// + /// #### Parameters + /// + /// - `request`: the dialog description and the content to save + /// - `callback`: receives the result on the EDT once the content is written, + /// also when the user cancels + public void showSaveDialog(SaveFileRequest request, SuccessCallback callback) { + impl.showSaveDialog(request, callback); + } + + /// Checks whether this platform's file dialogs have a capability. + /// + /// #### Parameters + /// + /// - `feature`: a `com.codename1.io.filechooser.FileDialogFeatures` constant + /// + /// #### Returns + /// + /// true if supported + public boolean isFileDialogFeatureSupported(int feature) { + return impl.isFileDialogFeatureSupported(feature); + } + /// Checks to see if the given gallery type is supported on the current platform. /// /// #### Parameters diff --git a/maven/core-unittests/src/test/java/com/codename1/impl/FileDialogSupportTest.java b/maven/core-unittests/src/test/java/com/codename1/impl/FileDialogSupportTest.java new file mode 100644 index 00000000000..42f6aa37d66 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/impl/FileDialogSupportTest.java @@ -0,0 +1,20 @@ +package com.codename1.impl; + +import com.codename1.io.filechooser.FileTypeFilter; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class FileDialogSupportTest { + + @Test + void withExtensionAppendsTheFiltersFirstExtensionOnlyWhenMissing() { + FileTypeFilter pdf = new FileTypeFilter("PDF").extensions("pdf", "PDFA"); + assertEquals("r.pdf", FileDialogSupport.withExtension("r", pdf)); + assertEquals("r.PDF", FileDialogSupport.withExtension("r.PDF", pdf)); + assertEquals("r.pdfa", FileDialogSupport.withExtension("r.pdfa", pdf)); + assertEquals("r.txt.pdf", FileDialogSupport.withExtension("r.txt", pdf)); + assertEquals("r", FileDialogSupport.withExtension("r", null)); + assertEquals("r", FileDialogSupport.withExtension("r", new FileTypeFilter("Any"))); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/io/filechooser/FileDialogRequestTest.java b/maven/core-unittests/src/test/java/com/codename1/io/filechooser/FileDialogRequestTest.java new file mode 100644 index 00000000000..6d2ef97caaf --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/io/filechooser/FileDialogRequestTest.java @@ -0,0 +1,98 @@ +package com.codename1.io.filechooser; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class FileDialogRequestTest { + + @Test + void filterNormalizesExtensionsAndMatchesNames() { + FileTypeFilter f = new FileTypeFilter("Images").extensions(".PNG", " jpg ", "", null); + assertArrayEquals(new String[]{"PNG", "jpg"}, f.getExtensions()); + assertTrue(f.acceptsName("a.png")); + assertTrue(f.acceptsName("/x/b.JPG")); + assertFalse(f.acceptsName("png")); + assertFalse(f.acceptsName("a.jpeg")); + assertTrue(new FileTypeFilter("All").acceptsName("anything")); + } + + @Test + void acceptStringJoinsExtensionsAndMimeTypes() { + OpenFileRequest r = new OpenFileRequest() + .addFilter(new FileTypeFilter("PDF").extensions("pdf").mimeTypes("application/pdf")) + .addFilter(new FileTypeFilter("Text").extensions("txt")); + assertEquals("pdf,application/pdf,txt", r.toAcceptString()); + assertNull(new OpenFileRequest().toAcceptString()); + } + + @Test + void selectedFilterIsClamped() { + SaveFileRequest r = new SaveFileRequest(); + assertEquals(-1, r.getSelectedFilter()); + r.addFilter(new FileTypeFilter("A")).addFilter(new FileTypeFilter("B")).selectedFilter(1); + assertEquals(1, r.getSelectedFilter()); + r.selectedFilter(7); + assertEquals(0, r.getSelectedFilter()); + } + + @Test + void hintsAreTyped() { + OpenFileRequest r = new OpenFileRequest() + .hint(FileDialogHints.MAC_SHOWS_HIDDEN_FILES, Boolean.TRUE) + .hint(FileDialogHints.WEB_START_IN, "documents") + .hint("x.flag", "TRUE"); + assertTrue(r.getBooleanHint(FileDialogHints.MAC_SHOWS_HIDDEN_FILES, false)); + assertTrue(r.getBooleanHint("x.flag", false)); + assertTrue(r.getBooleanHint("missing", true)); + assertEquals("documents", r.getStringHint(FileDialogHints.WEB_START_IN)); + assertNull(r.getStringHint("missing")); + } + + @Test + void resultFillsOptionDefaultsAndFilter() { + SaveFileRequest r = new SaveFileRequest() + .addFilter(new FileTypeFilter("PDF").extensions("pdf")) + .addFilter(new FileTypeFilter("Text").extensions("txt")) + .addOption(FileDialogOption.checkbox("charts", "Charts", true)) + .addOption(FileDialogOption.choice("q", "Quality", "High", "Low")) + .addOption(FileDialogOption.text("t", "Title", "x")); + Map chosen = new HashMap(); + chosen.put("q", "Low"); + FileDialogResult res = new FileDialogResult(r, new String[]{"/a/b.txt"}, 1, chosen); + assertFalse(res.isCancelled()); + assertEquals("/a/b.txt", res.getPath()); + assertEquals("Text", res.getSelectedFilter().getLabel()); + assertEquals("true", res.getOptionValue("charts")); + assertEquals("Low", res.getOptionValue("q")); + assertEquals("x", res.getOptionValue("t")); + + FileDialogResult cancelled = FileDialogResult.cancelled(r); + assertTrue(cancelled.isCancelled()); + assertNull(cancelled.getPath()); + assertEquals(0, cancelled.getSelectedFilterIndex()); + assertEquals("High", cancelled.getOptionValue("q")); + } + + @Test + void writeContentPrefersTheWriterAndFailsWithoutContent() throws IOException { + SaveFileRequest r = new SaveFileRequest().sourceFile("/never/read").content(new SaveContent() { + @Override + public void write(FileDialogResult result, OutputStream out) throws IOException { + out.write(result.getPath().getBytes("UTF-8")); + } + }); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + r.writeContent(new FileDialogResult(r, new String[]{"p"}, -1, null), out); + assertEquals("p", out.toString("UTF-8")); + + SaveFileRequest empty = new SaveFileRequest(); + assertThrows(IOException.class, () -> empty.writeContent(FileDialogResult.cancelled(empty), out)); + } +} From 97b2fd33ac754a8aaef2898f22311c4434218cc8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:20:43 +0300 Subject: [PATCH 02/31] JavaSE: native file open/save dialogs with filter list, options and preview Co-Authored-By: Claude Opus 5.5 --- .../io/filechooser/FileDialogRequest.java | 4 +- .../impl/javase/JavaSEFileDialogs.java | 397 ++++++++++++++++++ .../com/codename1/impl/javase/JavaSEPort.java | 23 + 3 files changed, 422 insertions(+), 2 deletions(-) create mode 100644 Ports/JavaSE/src/com/codename1/impl/javase/JavaSEFileDialogs.java diff --git a/CodenameOne/src/com/codename1/io/filechooser/FileDialogRequest.java b/CodenameOne/src/com/codename1/io/filechooser/FileDialogRequest.java index 1e89ac3074e..d22e8a24365 100644 --- a/CodenameOne/src/com/codename1/io/filechooser/FileDialogRequest.java +++ b/CodenameOne/src/com/codename1/io/filechooser/FileDialogRequest.java @@ -177,8 +177,8 @@ public T addOption(FileDialogOption option) { /// Embeds a Codename One component in the dialog, such as a format and /// quality panel for an export. Only shown where - /// `FileDialogFeatures#ACCESSORY_COMPONENT` is supported (macOS and the - /// desktop/simulator port); use `#addOption(FileDialogOption)` for + /// `FileDialogFeatures#ACCESSORY_COMPONENT` is supported (the native macOS + /// port and the Codename One fallback dialog); use `#addOption(FileDialogOption)` for /// controls that must appear on every desktop. The component is still /// reachable from the callback, so its state can be read after the dialog /// closes. diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEFileDialogs.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEFileDialogs.java new file mode 100644 index 00000000000..f55aa3e814f --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEFileDialogs.java @@ -0,0 +1,397 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.impl.javase; + +import com.codename1.io.filechooser.FileDialogOption; +import com.codename1.io.filechooser.FileDialogRequest; +import com.codename1.io.filechooser.FileDialogResult; +import com.codename1.io.filechooser.FilePreviewProvider; +import com.codename1.io.filechooser.FileTypeFilter; +import com.codename1.io.filechooser.OpenFileRequest; +import com.codename1.io.filechooser.SaveFileRequest; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.util.SuccessCallback; + +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.awt.EventQueue; +import java.awt.FileDialog; +import java.awt.Frame; +import java.beans.PropertyChangeEvent; +import java.beans.PropertyChangeListener; +import java.io.File; +import java.io.FilenameFilter; +import java.util.HashMap; +import java.util.Map; +import javax.swing.BoxLayout; +import javax.swing.ImageIcon; +import javax.swing.JCheckBox; +import javax.swing.JComboBox; +import javax.swing.JComponent; +import javax.swing.JFileChooser; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JTextField; +import javax.swing.SwingConstants; +import javax.swing.filechooser.FileFilter; + +/// The simulator and desktop file dialogs. +/// +/// A plain request uses AWT's `FileDialog`, which is the operating system's own +/// panel. A request that needs what that panel cannot do -- a filter list, +/// options, a preview or folder picking -- uses Swing's `JFileChooser`, whose +/// accessory hosts the options and a rendering of the Codename One preview. +/// A Codename One accessory component cannot be embedded in either, so +/// `FileDialogFeatures#ACCESSORY_COMPONENT` is not reported here. +final class JavaSEFileDialogs { + private static final int PREVIEW_SIZE = 220; + + private JavaSEFileDialogs() { + } + + interface SaveCompleter { + void complete(SaveFileRequest request, FileDialogResult result, SuccessCallback callback); + } + + static void showOpen(final OpenFileRequest request, final SuccessCallback callback) { + EventQueue.invokeLater(new Runnable() { + public void run() { + final FileDialogResult result = needsSwing(request) ? openSwing(request) : openAwt(request); + Display.getInstance().callSerially(new Runnable() { + public void run() { + callback.onSucess(result); + } + }); + } + }); + } + + static void showSave(final SaveFileRequest request, final SuccessCallback callback, + final SaveCompleter completer) { + EventQueue.invokeLater(new Runnable() { + public void run() { + final FileDialogResult result = needsSwing(request) ? saveSwing(request) : saveAwt(request); + Display.getInstance().callSerially(new Runnable() { + public void run() { + if (result.isCancelled()) { + callback.onSucess(result); + } else { + completer.complete(request, result, callback); + } + } + }); + } + }); + } + + private static boolean needsSwing(FileDialogRequest request) { + if (request.getFilters().length > 1 || request.getOptions().length > 0 + || request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.DESKTOP_SHOW_HIDDEN, false)) { + return true; + } + if (request instanceof OpenFileRequest) { + OpenFileRequest o = (OpenFileRequest) request; + return o.getPreview() != null || o.isDirectories(); + } + return false; + } + + private static Frame parentFrame() { + Frame[] frames = Frame.getFrames(); + for (int iter = 0; iter < frames.length; iter++) { + if (frames[iter].isShowing()) { + return frames[iter]; + } + } + return null; + } + + private static File toFile(String path) { + if (path == null) { + return null; + } + if (path.startsWith("file://")) { + path = path.substring(7); + } + return new File(path); + } + + private static String toPath(File f) { + return "file://" + f.getAbsolutePath().replace('\\', '/'); + } + + private static String title(FileDialogRequest request, String def) { + return request.getTitle() == null ? def : request.getTitle(); + } + + private static FileDialogResult openAwt(OpenFileRequest request) { + FileDialog picker = new FileDialog(parentFrame(), title(request, "Open"), FileDialog.LOAD); + picker.setMultipleMode(request.isMultiSelect()); + File dir = toFile(request.getInitialDirectory()); + if (dir != null) { + picker.setDirectory(dir.getAbsolutePath()); + } + final int sel = request.getSelectedFilter(); + if (sel >= 0) { + final FileTypeFilter filter = request.getFilters()[sel]; + picker.setFilenameFilter(new FilenameFilter() { + public boolean accept(File d, String name) { + return filter.acceptsName(name); + } + }); + } + picker.setVisible(true); + File[] files = picker.getFiles(); + if (files == null || files.length == 0) { + return FileDialogResult.cancelled(request); + } + String[] paths = new String[files.length]; + for (int i = 0; i < files.length; i++) { + paths[i] = toPath(files[i]); + } + return new FileDialogResult(request, paths, sel, null); + } + + private static FileDialogResult saveAwt(SaveFileRequest request) { + FileDialog picker = new FileDialog(parentFrame(), title(request, "Save"), FileDialog.SAVE); + File dir = toFile(request.getInitialDirectory()); + if (dir != null) { + picker.setDirectory(dir.getAbsolutePath()); + } + if (request.getSuggestedName() != null) { + picker.setFile(request.getSuggestedName()); + } + picker.setVisible(true); + if (picker.getFile() == null) { + return FileDialogResult.cancelled(request); + } + int sel = request.getSelectedFilter(); + String name = withExtension(picker.getFile(), sel < 0 ? null : request.getFilters()[sel]); + return new FileDialogResult(request, new String[]{toPath(new File(picker.getDirectory(), name))}, sel, null); + } + + private static JFileChooser createChooser(FileDialogRequest request, JComponent[] editors) { + JFileChooser chooser = new JFileChooser(); + chooser.setDialogTitle(request.getTitle()); + File dir = toFile(request.getInitialDirectory()); + if (dir != null) { + chooser.setCurrentDirectory(dir); + } + chooser.setFileHidingEnabled(!request.getBooleanHint( + com.codename1.io.filechooser.FileDialogHints.DESKTOP_SHOW_HIDDEN, false)); + if (request.getAcceptLabel() != null) { + chooser.setApproveButtonText(request.getAcceptLabel()); + } + FileTypeFilter[] filters = request.getFilters(); + if (filters.length > 0) { + chooser.setAcceptAllFileFilterUsed(false); + for (int i = 0; i < filters.length; i++) { + chooser.addChoosableFileFilter(new SwingFilter(filters[i], i)); + } + chooser.setFileFilter(chooser.getChoosableFileFilters()[request.getSelectedFilter()]); + } + FileDialogOption[] options = request.getOptions(); + if (options.length > 0) { + JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + for (int i = 0; i < options.length; i++) { + FileDialogOption o = options[i]; + if (o.getType() == FileDialogOption.TYPE_CHECKBOX) { + editors[i] = new JCheckBox(o.getLabel(), "true".equals(o.getDefaultValue())); + } else { + panel.add(new JLabel(o.getLabel())); + if (o.getType() == FileDialogOption.TYPE_CHOICE) { + JComboBox box = new JComboBox(o.getChoices()); + box.setSelectedItem(o.getDefaultValue()); + editors[i] = box; + } else { + editors[i] = new JTextField(o.getDefaultValue() == null ? "" : o.getDefaultValue(), 12); + } + } + editors[i].setAlignmentX(JComponent.LEFT_ALIGNMENT); + panel.add(editors[i]); + } + chooser.setAccessory(panel); + } + return chooser; + } + + private static Map optionValues(FileDialogRequest request, JComponent[] editors) { + Map values = new HashMap(); + FileDialogOption[] options = request.getOptions(); + for (int i = 0; i < options.length; i++) { + JComponent c = editors[i]; + String v; + if (c instanceof JCheckBox) { + v = ((JCheckBox) c).isSelected() ? "true" : "false"; + } else if (c instanceof JComboBox) { + Object o = ((JComboBox) c).getSelectedItem(); + v = o == null ? null : o.toString(); + } else { + v = ((JTextField) c).getText(); + } + values.put(options[i].getId(), v); + } + return values; + } + + private static int filterIndex(JFileChooser chooser) { + FileFilter f = chooser.getFileFilter(); + return f instanceof SwingFilter ? ((SwingFilter) f).index : -1; + } + + private static FileDialogResult openSwing(OpenFileRequest request) { + JComponent[] editors = new JComponent[request.getOptions().length]; + JFileChooser chooser = createChooser(request, editors); + chooser.setMultiSelectionEnabled(request.isMultiSelect()); + if (request.isDirectories()) { + chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + } + if (request.getPreview() != null) { + installPreview(chooser, request.getPreview()); + } + if (chooser.showOpenDialog(parentFrame()) != JFileChooser.APPROVE_OPTION) { + return FileDialogResult.cancelled(request); + } + File[] files = request.isMultiSelect() ? chooser.getSelectedFiles() : new File[]{chooser.getSelectedFile()}; + if (files == null || files.length == 0 || files[0] == null) { + return FileDialogResult.cancelled(request); + } + String[] paths = new String[files.length]; + for (int i = 0; i < files.length; i++) { + paths[i] = toPath(files[i]); + } + return new FileDialogResult(request, paths, filterIndex(chooser), optionValues(request, editors)); + } + + private static FileDialogResult saveSwing(SaveFileRequest request) { + JComponent[] editors = new JComponent[request.getOptions().length]; + JFileChooser chooser = createChooser(request, editors); + File dir = chooser.getCurrentDirectory(); + if (request.getSuggestedName() != null) { + chooser.setSelectedFile(new File(dir, request.getSuggestedName())); + } + while (true) { + if (chooser.showSaveDialog(parentFrame()) != JFileChooser.APPROVE_OPTION || chooser.getSelectedFile() == null) { + return FileDialogResult.cancelled(request); + } + int sel = filterIndex(chooser); + File f = chooser.getSelectedFile(); + f = new File(f.getParentFile(), withExtension(f.getName(), sel < 0 ? null : request.getFilters()[sel])); + if (f.exists() && request.isConfirmOverwrite() + && JOptionPane.showConfirmDialog(chooser, f.getName() + " already exists. Replace it?", "Replace", + JOptionPane.YES_NO_OPTION) != JOptionPane.YES_OPTION) { + continue; + } + return new FileDialogResult(request, new String[]{toPath(f)}, sel, optionValues(request, editors)); + } + } + + static String withExtension(String name, FileTypeFilter filter) { + if (filter == null || filter.acceptsName(name) || filter.getExtensions().length == 0) { + return name; + } + return name + "." + filter.getExtensions()[0]; + } + + /// Shows the Codename One preview beside the chooser: the provider runs on + /// the EDT, its component is painted into an image there, and the image is + /// handed back to Swing. + private static void installPreview(JFileChooser chooser, final FilePreviewProvider provider) { + final JLabel label = new JLabel("", SwingConstants.CENTER); + label.setPreferredSize(new Dimension(PREVIEW_SIZE, PREVIEW_SIZE)); + JPanel holder = new JPanel(new BorderLayout()); + holder.add(label, BorderLayout.CENTER); + if (chooser.getAccessory() != null) { + holder.add(chooser.getAccessory(), BorderLayout.SOUTH); + } + chooser.setAccessory(holder); + chooser.addPropertyChangeListener(JFileChooser.SELECTED_FILE_CHANGED_PROPERTY, new PropertyChangeListener() { + public void propertyChange(PropertyChangeEvent evt) { + final File f = (File) evt.getNewValue(); + label.setIcon(null); + if (f == null || f.isDirectory()) { + return; + } + Display.getInstance().callSerially(new Runnable() { + public void run() { + final java.awt.Image img = renderPreview(provider, toPath(f)); + EventQueue.invokeLater(new Runnable() { + public void run() { + label.setIcon(img == null ? null : new ImageIcon(img)); + } + }); + } + }); + } + }); + } + + private static java.awt.Image renderPreview(FilePreviewProvider provider, String path) { + Component cmp; + try { + cmp = provider.createPreview(path); + } catch (RuntimeException err) { + com.codename1.io.Log.e(err); + return null; + } + if (cmp == null) { + return null; + } + Dimension size = new Dimension(Math.min(PREVIEW_SIZE, Math.max(1, cmp.getPreferredW())), + Math.min(PREVIEW_SIZE, Math.max(1, cmp.getPreferredH()))); + cmp.setX(0); + cmp.setY(0); + cmp.setWidth(size.width); + cmp.setHeight(size.height); + if (cmp instanceof Container) { + ((Container) cmp).layoutContainer(); + } + com.codename1.ui.Image img = com.codename1.ui.Image.createImage(size.width, size.height, 0); + cmp.paintComponent(img.getGraphics(), true); + Object peer = img.getImage(); + return peer instanceof java.awt.Image ? (java.awt.Image) peer : null; + } + + private static final class SwingFilter extends FileFilter { + private final FileTypeFilter filter; + private final int index; + + SwingFilter(FileTypeFilter filter, int index) { + this.filter = filter; + this.index = index; + } + + public boolean accept(File f) { + return f.isDirectory() || filter.acceptsName(f.getName()); + } + + public String getDescription() { + return filter.getLabel(); + } + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index d8cbd46d321..82059987971 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -18739,6 +18739,29 @@ public void run() { }); } + @Override + public void showOpenDialog(com.codename1.io.filechooser.OpenFileRequest request, + com.codename1.util.SuccessCallback callback) { + JavaSEFileDialogs.showOpen(request, callback); + } + + @Override + public void showSaveDialog(com.codename1.io.filechooser.SaveFileRequest request, + com.codename1.util.SuccessCallback callback) { + JavaSEFileDialogs.showSave(request, callback, new JavaSEFileDialogs.SaveCompleter() { + public void complete(com.codename1.io.filechooser.SaveFileRequest r, + com.codename1.io.filechooser.FileDialogResult result, + com.codename1.util.SuccessCallback cb) { + completeSaveDialog(r, result, cb); + } + }); + } + + @Override + public boolean isFileDialogFeatureSupported(int feature) { + return feature != com.codename1.io.filechooser.FileDialogFeatures.ACCESSORY_COMPONENT; + } + @Override public void openFileChooser(final com.codename1.ui.events.ActionListener response, final String accept) { EventQueue.invokeLater(new Runnable() { From 9a3fb57867273face885dfdc4fa720185a309cc4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:22:12 +0300 Subject: [PATCH 03/31] JavaScript port: open dialog via the browser picker, save as a named download Co-Authored-By: Claude Opus 5.5 --- .../impl/html5/HTML5Implementation.java | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java index 20a4c2ac390..ec055ed212f 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java @@ -6584,6 +6584,85 @@ public void openGallery(ActionListener response, int type) { chooser.showDialog(); } + // File dialogs. Opening uses the browser's own picker. + // Saving downloads the content: showSaveFilePicker would let the user pick + // a folder, but it needs a user gesture on the main thread and the content + // is only ready after the worker has written it, so the gesture is gone + // by then. The download keeps the suggested name and the selected + // filter's extension. + @Override + public void showOpenDialog(final com.codename1.io.filechooser.OpenFileRequest request, + final com.codename1.util.SuccessCallback callback) { + StringBuilder accept = new StringBuilder(); + for (com.codename1.io.filechooser.FileTypeFilter f : request.getFilters()) { + for (String e : f.getExtensions()) { + accept.append(accept.length() == 0 ? "." : ",.").append(e); + } + for (String m : f.getMimeTypes()) { + accept.append(accept.length() == 0 ? "" : ",").append(m); + } + } + com.codename1.teavm.ext.usermedia.FileChooser chooser = new com.codename1.teavm.ext.usermedia.FileChooser( + new ActionListener() { + public void actionPerformed(ActionEvent evt) { + Object src = evt == null ? null : evt.getSource(); + String[] paths = null; + if (src instanceof String[]) { + paths = (String[]) src; + } else if (src instanceof String) { + paths = new String[]{(String) src}; + } + callback.onSucess(new com.codename1.io.filechooser.FileDialogResult(request, paths, -1, null)); + } + }, accept.length() == 0 ? null : accept.toString(), null); + chooser.setMultiple(request.isMultiSelect()); + chooser.showDialog(); + } + + @Override + public void showSaveDialog(final com.codename1.io.filechooser.SaveFileRequest request, + final com.codename1.util.SuccessCallback callback) { + String name = request.getSuggestedName(); + if (name == null || name.length() == 0) { + name = "download"; + } + int sel = request.getSelectedFilter(); + if (sel >= 0) { + com.codename1.io.filechooser.FileTypeFilter f = request.getFilters()[sel]; + if (!f.acceptsName(name) && f.getExtensions().length > 0) { + name = name + "." + f.getExtensions()[0]; + } + } + final String fileName = name; + final com.codename1.io.filechooser.FileDialogResult result = + new com.codename1.io.filechooser.FileDialogResult(request, new String[]{fileName}, sel, null); + Display.getInstance().scheduleBackgroundTask(new Runnable() { + public void run() { + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + try { + request.writeContent(result, out); + if (!downloadBytesAsFile(fileName, out.toByteArray())) { + result.setError(new IOException("The browser refused the download")); + } + } catch (IOException err) { + Log.e(err); + result.setError(err); + } + Display.getInstance().callSerially(new Runnable() { + public void run() { + callback.onSucess(result); + } + }); + } + }); + } + + @Override + public boolean isFileDialogFeatureSupported(int feature) { + return feature == com.codename1.io.filechooser.FileDialogFeatures.NATIVE + || feature == com.codename1.io.filechooser.FileDialogFeatures.MULTI_SELECT; + } + @Override public void captureVideo(ActionListener response) { new com.codename1.teavm.ext.usermedia.FileChooser(response, "video/*", "camcorder").showDialog(); From ce13870c7e5a03f673d8e11a7ce700d4629dda33 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:24:44 +0300 Subject: [PATCH 04/31] Linux port: GtkFileChooser open/save dialogs with filters, choices and options Co-Authored-By: Claude Opus 5.5 --- .../codename1/impl/NativeFileDialogCodec.java | 157 ++++++++++ .../nativeSources/cn1_linux_services.c | 274 ++++++++++++++++++ .../impl/linux/LinuxImplementation.java | 42 +++ .../com/codename1/impl/linux/LinuxNative.java | 11 + 4 files changed, 484 insertions(+) create mode 100644 CodenameOne/src/com/codename1/impl/NativeFileDialogCodec.java diff --git a/CodenameOne/src/com/codename1/impl/NativeFileDialogCodec.java b/CodenameOne/src/com/codename1/impl/NativeFileDialogCodec.java new file mode 100644 index 00000000000..2434d30dacd --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/NativeFileDialogCodec.java @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.impl; + +import com.codename1.io.filechooser.FileDialogOption; +import com.codename1.io.filechooser.FileDialogRequest; +import com.codename1.io.filechooser.FileDialogResult; +import com.codename1.io.filechooser.FileTypeFilter; + +import java.util.HashMap; +import java.util.Map; + +/// The flat string form in which the desktop ports (Linux, Windows, macOS) +/// pass a file dialog request to their natives and read the answer back. +/// +/// Natives take plain strings and string arrays, so a filter travels as +/// `"label\next1\next2..."` and an option as +/// `"type\nid\nlabel\ndefault\nchoice1\nchoice2..."`, where type is the +/// `FileDialogOption` type constant. The native answers `null` on cancel, else +/// `[selected filter index, one value per option, path...]`. +public final class NativeFileDialogCodec { + private NativeFileDialogCodec() { + } + + /// Encodes the request's filters. + /// + /// #### Parameters + /// + /// - `request`: the request + /// + /// #### Returns + /// + /// one string per filter + public static String[] encodeFilters(FileDialogRequest request) { + FileTypeFilter[] filters = request.getFilters(); + String[] out = new String[filters.length]; + for (int i = 0; i < filters.length; i++) { + StringBuilder sb = new StringBuilder(clean(filters[i].getLabel())); + for (String e : filters[i].getExtensions()) { + sb.append('\n').append(clean(e)); + } + out[i] = sb.toString(); + } + return out; + } + + /// Encodes the request's options. + /// + /// #### Parameters + /// + /// - `request`: the request + /// + /// #### Returns + /// + /// one string per option + public static String[] encodeOptions(FileDialogRequest request) { + FileDialogOption[] options = request.getOptions(); + String[] out = new String[options.length]; + for (int i = 0; i < options.length; i++) { + FileDialogOption o = options[i]; + StringBuilder sb = new StringBuilder(); + sb.append(o.getType()).append('\n').append(clean(o.getId())).append('\n') + .append(clean(o.getLabel())).append('\n').append(clean(o.getDefaultValue())); + for (String c : o.getChoices()) { + sb.append('\n').append(clean(c)); + } + out[i] = sb.toString(); + } + return out; + } + + /// Turns a `FileSystemStorage` path into a native one. + /// + /// #### Parameters + /// + /// - `path`: a path, possibly a `file://` URL + /// + /// #### Returns + /// + /// the native path, or null + public static String toNativePath(String path) { + if (path == null) { + return null; + } + if (path.startsWith("file://")) { + return path.substring(7); + } + return path; + } + + /// Decodes the native answer. + /// + /// #### Parameters + /// + /// - `request`: the request being answered + /// - `answer`: what the native returned + /// + /// #### Returns + /// + /// the result; cancelled when the answer is null or has no path + public static FileDialogResult decode(FileDialogRequest request, String[] answer) { + FileDialogOption[] options = request.getOptions(); + int first = 1 + options.length; + if (answer == null || answer.length <= first) { + return FileDialogResult.cancelled(request); + } + int filter = -1; + try { + filter = Integer.parseInt(answer[0]); + } catch (NumberFormatException ignored) { + // the native always sends a number; -1 falls back to the request's choice + } + if (filter >= request.getFilters().length) { + filter = -1; + } + Map values = new HashMap(); + for (int i = 0; i < options.length; i++) { + if (answer[1 + i] != null) { + values.put(options[i].getId(), answer[1 + i]); + } + } + String[] paths = new String[answer.length - first]; + for (int i = 0; i < paths.length; i++) { + paths[i] = "file://" + answer[first + i].replace('\\', '/'); + } + if (request instanceof com.codename1.io.filechooser.SaveFileRequest && filter >= 0) { + FileTypeFilter f = request.getFilters()[filter]; + paths[0] = FileDialogSupport.withExtension(paths[0], f); + } + return new FileDialogResult(request, paths, filter, values); + } + + private static String clean(String s) { + return s == null ? "" : s.replace('\n', ' '); + } +} diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_services.c b/Ports/LinuxPort/nativeSources/cn1_linux_services.c index 5c2ec35b5e7..c94c1052103 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_services.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_services.c @@ -42,6 +42,7 @@ #include #include #include +#include #include #include @@ -685,6 +686,279 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_fileDialog___boolean_int_java_l return d.got ? newStringFromCString(threadStateData, d.result) : JAVA_NULL; } +/* ------------------------------------------------- rich open/save dialog + * The dialog behind Display.showOpenDialog/showSaveDialog. Every Java string is + * copied into C memory before the hop to the GTK thread, because that thread + * must not touch the Java heap; the answer comes back the same way and is only + * turned into Java strings on the calling thread. + * + * filters[i] is "label\next1\next2...", options[i] is + * "type\nid\nlabel\ndefault\nchoice1\nchoice2..." (type 1 checkbox, 2 choice, + * 3 text). The answer is null on cancel, else + * [selected filter index, one value per option, path...]. */ + +#define CN1_FDX_MAX_ITEMS 64 + +typedef struct { + int save; + int multi; + int dirs; + int confirmOverwrite; + int showHidden; + char* title; + char* acceptLabel; + char* initialDir; + char* suggestedName; + int filterCount; + char* filters[CN1_FDX_MAX_ITEMS]; + int selectedFilter; + int optionCount; + char* options[CN1_FDX_MAX_ITEMS]; + /* answer */ + int accepted; + int chosenFilter; + char* values[CN1_FDX_MAX_ITEMS]; + GSList* paths; +} CN1FileDlgEx; + +static char* cn1FdxDup(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT s) { + const char* c; + if (s == JAVA_NULL) { + return 0; + } + c = stringToUTF8(threadStateData, s); + return c ? strdup(c) : 0; +} + +static int cn1FdxDupArray(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT arr, char** out) { + int n, i; + JAVA_OBJECT* elements; + if (arr == JAVA_NULL) { + return 0; + } + n = (int) (*(JAVA_ARRAY) arr).length; + if (n > CN1_FDX_MAX_ITEMS) { + n = CN1_FDX_MAX_ITEMS; + } + elements = (JAVA_OBJECT*) CN1_ARRAY_DATA(arr); + for (i = 0; i < n; i++) { + out[i] = cn1FdxDup(threadStateData, elements[i]); + } + return n; +} + +/* Splits a "\n" separated spec in place; returns the number of fields. */ +static int cn1FdxSplit(char* spec, char** fields, int max) { + int n = 0; + char* p = spec; + if (!spec) { + return 0; + } + while (n < max) { + char* nl = strchr(p, '\n'); + fields[n++] = p; + if (!nl) { + break; + } + *nl = 0; + p = nl + 1; + } + return n; +} + +static void cn1FileDialogExOnMain(void* p) { + CN1FileDlgEx* d = (CN1FileDlgEx*) p; + GtkFileChooserAction action; + GtkWidget* dialog; + GtkWidget* extra = 0; + GtkWidget* entries[CN1_FDX_MAX_ITEMS]; + GtkFileFilter* filters[CN1_FDX_MAX_ITEMS]; + int i; + if (d->save) { + action = GTK_FILE_CHOOSER_ACTION_SAVE; + } else { + action = d->dirs ? GTK_FILE_CHOOSER_ACTION_SELECT_FOLDER : GTK_FILE_CHOOSER_ACTION_OPEN; + } + dialog = gtk_file_chooser_dialog_new( + d->title ? d->title : (d->save ? "Save" : "Open"), + GTK_WINDOW(cn1LinuxWindowWidget()), action, + "_Cancel", GTK_RESPONSE_CANCEL, + d->acceptLabel ? d->acceptLabel : (d->save ? "_Save" : "_Open"), GTK_RESPONSE_ACCEPT, NULL); + gtk_file_chooser_set_select_multiple(GTK_FILE_CHOOSER(dialog), d->multi && !d->save); + gtk_file_chooser_set_show_hidden(GTK_FILE_CHOOSER(dialog), d->showHidden); + gtk_file_chooser_set_do_overwrite_confirmation(GTK_FILE_CHOOSER(dialog), d->confirmOverwrite); + if (d->initialDir) { + gtk_file_chooser_set_current_folder(GTK_FILE_CHOOSER(dialog), d->initialDir); + } + if (d->save && d->suggestedName) { + gtk_file_chooser_set_current_name(GTK_FILE_CHOOSER(dialog), d->suggestedName); + } + for (i = 0; i < d->filterCount; i++) { + char* fields[CN1_FDX_MAX_ITEMS]; + int n = cn1FdxSplit(d->filters[i], fields, CN1_FDX_MAX_ITEMS); + int j; + filters[i] = gtk_file_filter_new(); + gtk_file_filter_set_name(filters[i], n > 0 ? fields[0] : ""); + for (j = 1; j < n; j++) { + /* case-insensitive glob, so "png" also matches IMG.PNG */ + char glob[256]; + int k, o = 0; + glob[o++] = '*'; + glob[o++] = '.'; + for (k = 0; fields[j][k] && o < (int) sizeof(glob) - 5; k++) { + char c = fields[j][k]; + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) { + glob[o++] = '['; + glob[o++] = (char) (c | 0x20); + glob[o++] = (char) (c & ~0x20); + glob[o++] = ']'; + } else { + glob[o++] = c; + } + } + glob[o] = 0; + gtk_file_filter_add_pattern(filters[i], glob); + } + if (n <= 1) { + gtk_file_filter_add_pattern(filters[i], "*"); + } + gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(dialog), filters[i]); + } + if (d->filterCount > 0 && d->selectedFilter >= 0 && d->selectedFilter < d->filterCount) { + gtk_file_chooser_set_filter(GTK_FILE_CHOOSER(dialog), filters[d->selectedFilter]); + } + /* Checkboxes and choices are GtkFileChooser choices, which the desktop + * portal can show too; a text option has no choice form and goes into the + * extra widget. */ + for (i = 0; i < d->optionCount; i++) { + char* fields[CN1_FDX_MAX_ITEMS]; + int n = cn1FdxSplit(d->options[i], fields, CN1_FDX_MAX_ITEMS - 1); + entries[i] = 0; + if (n < 4) { + continue; + } + if (fields[0][0] == '1') { + gtk_file_chooser_add_choice(GTK_FILE_CHOOSER(dialog), fields[1], fields[2], NULL, NULL); + gtk_file_chooser_set_choice(GTK_FILE_CHOOSER(dialog), fields[1], fields[3]); + } else if (fields[0][0] == '2') { + const char* ids[CN1_FDX_MAX_ITEMS]; + int k; + for (k = 4; k < n; k++) { + ids[k - 4] = fields[k]; + } + ids[n - 4] = NULL; + gtk_file_chooser_add_choice(GTK_FILE_CHOOSER(dialog), fields[1], fields[2], ids, ids); + gtk_file_chooser_set_choice(GTK_FILE_CHOOSER(dialog), fields[1], fields[3]); + } else { + GtkWidget* label = gtk_label_new(fields[2]); + if (!extra) { + extra = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + } + entries[i] = gtk_entry_new(); + gtk_entry_set_text(GTK_ENTRY(entries[i]), fields[3]); + gtk_box_pack_start(GTK_BOX(extra), label, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(extra), entries[i], FALSE, FALSE, 0); + } + } + if (extra) { + gtk_widget_show_all(extra); + gtk_file_chooser_set_extra_widget(GTK_FILE_CHOOSER(dialog), extra); + } + if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) { + GtkFileFilter* chosen = gtk_file_chooser_get_filter(GTK_FILE_CHOOSER(dialog)); + d->accepted = 1; + d->chosenFilter = -1; + for (i = 0; i < d->filterCount; i++) { + if (filters[i] == chosen) { + d->chosenFilter = i; + } + } + for (i = 0; i < d->optionCount; i++) { + const char* v = 0; + if (entries[i]) { + v = gtk_entry_get_text(GTK_ENTRY(entries[i])); + } else if (d->options[i]) { + /* cn1FdxSplit left the id as the second field */ + const char* id = d->options[i] + strlen(d->options[i]) + 1; + v = gtk_file_chooser_get_choice(GTK_FILE_CHOOSER(dialog), id); + } + d->values[i] = v ? strdup(v) : 0; + } + d->paths = gtk_file_chooser_get_filenames(GTK_FILE_CHOOSER(dialog)); + } + gtk_widget_destroy(dialog); +} + +JAVA_OBJECT com_codename1_impl_linux_LinuxNative_fileDialogEx___boolean_java_lang_String_java_lang_String_java_lang_String_java_lang_String_java_lang_String_1ARRAY_int_boolean_boolean_boolean_boolean_java_lang_String_1ARRAY_R_java_lang_String_1ARRAY( + CODENAME_ONE_THREAD_STATE, JAVA_BOOLEAN save, JAVA_OBJECT title, JAVA_OBJECT acceptLabel, + JAVA_OBJECT initialDir, JAVA_OBJECT suggestedName, JAVA_OBJECT filters, JAVA_INT selectedFilter, + JAVA_BOOLEAN multi, JAVA_BOOLEAN dirs, JAVA_BOOLEAN confirmOverwrite, JAVA_BOOLEAN showHidden, + JAVA_OBJECT options) { + CN1FileDlgEx d; + JAVA_OBJECT arr = JAVA_NULL; + JAVA_OBJECT* elements; + GSList* it; + int count, i, o; + char num[16]; + memset(&d, 0, sizeof(d)); + if (cn1LinuxWindowWidget() == 0) { + return JAVA_NULL; /* headless */ + } + d.save = save ? 1 : 0; + d.multi = multi ? 1 : 0; + d.dirs = dirs ? 1 : 0; + d.confirmOverwrite = confirmOverwrite ? 1 : 0; + d.showHidden = showHidden ? 1 : 0; + d.selectedFilter = selectedFilter; + d.title = cn1FdxDup(threadStateData, title); + d.acceptLabel = cn1FdxDup(threadStateData, acceptLabel); + d.initialDir = cn1FdxDup(threadStateData, initialDir); + d.suggestedName = cn1FdxDup(threadStateData, suggestedName); + d.filterCount = cn1FdxDupArray(threadStateData, filters, d.filters); + d.optionCount = cn1FdxDupArray(threadStateData, options, d.options); + cn1LinuxRunOnMainAndWait(cn1FileDialogExOnMain, &d); + if (d.accepted && d.paths) { + count = 1 + d.optionCount + (int) g_slist_length(d.paths); + arr = allocArray(threadStateData, count, &class_array1__java_lang_String, sizeof(JAVA_OBJECT), 1); + if (arr != JAVA_NULL) { + JAVA_OBJECT s; + o = 0; + snprintf(num, sizeof(num), "%d", d.chosenFilter); + s = newStringFromCString(threadStateData, num); + elements = (JAVA_OBJECT*) CN1_ARRAY_DATA(arr); + CN1_WRITE_BARRIER(arr, s); + elements[o++] = s; + for (i = 0; i < d.optionCount; i++) { + s = d.values[i] ? newStringFromCString(threadStateData, d.values[i]) : JAVA_NULL; + elements = (JAVA_OBJECT*) CN1_ARRAY_DATA(arr); + CN1_WRITE_BARRIER(arr, s); + elements[o++] = s; + } + for (it = d.paths; it; it = it->next) { + s = newStringFromCString(threadStateData, (const char*) it->data); + elements = (JAVA_OBJECT*) CN1_ARRAY_DATA(arr); + CN1_WRITE_BARRIER(arr, s); + elements[o++] = s; + } + } + } + if (d.paths) { + g_slist_free_full(d.paths, g_free); + } + free(d.title); + free(d.acceptLabel); + free(d.initialDir); + free(d.suggestedName); + for (i = 0; i < d.filterCount; i++) { + free(d.filters[i]); + } + for (i = 0; i < d.optionCount; i++) { + free(d.options[i]); + free(d.values[i]); + } + return arr; +} + /* ------------------------------------------------------------- location */ JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_locationGetCurrent___double_1ARRAY_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT out) { diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index 86b9ea3d282..0e8a4d3de7a 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -2606,6 +2606,48 @@ public void openGallery(com.codename1.ui.events.ActionListener response, int typ response.actionPerformed(result); } + /* GtkFileChooser for Display.showOpenDialog/showSaveDialog: filter list, + * multi selection, folders, overwrite confirmation, and the portable + * options as GtkFileChooser choices (text options in the extra widget). + * A Codename One accessory component and a preview cannot be hosted in a + * GTK dialog, so neither is reported supported. */ + @Override + public void showOpenDialog(com.codename1.io.filechooser.OpenFileRequest request, + com.codename1.util.SuccessCallback callback) { + String[] answer = LinuxNative.fileDialogEx(false, request.getTitle(), request.getAcceptLabel(), + com.codename1.impl.NativeFileDialogCodec.toNativePath(request.getInitialDirectory()), null, + com.codename1.impl.NativeFileDialogCodec.encodeFilters(request), request.getSelectedFilter(), + request.isMultiSelect(), request.isDirectories(), false, + request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.DESKTOP_SHOW_HIDDEN, false), + com.codename1.impl.NativeFileDialogCodec.encodeOptions(request)); + callback.onSucess(com.codename1.impl.NativeFileDialogCodec.decode(request, answer)); + } + + @Override + public void showSaveDialog(com.codename1.io.filechooser.SaveFileRequest request, + com.codename1.util.SuccessCallback callback) { + String[] answer = LinuxNative.fileDialogEx(true, request.getTitle(), request.getAcceptLabel(), + com.codename1.impl.NativeFileDialogCodec.toNativePath(request.getInitialDirectory()), + request.getSuggestedName(), + com.codename1.impl.NativeFileDialogCodec.encodeFilters(request), request.getSelectedFilter(), + false, false, request.isConfirmOverwrite(), + request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.DESKTOP_SHOW_HIDDEN, false), + com.codename1.impl.NativeFileDialogCodec.encodeOptions(request)); + com.codename1.io.filechooser.FileDialogResult result = + com.codename1.impl.NativeFileDialogCodec.decode(request, answer); + if (result.isCancelled()) { + callback.onSucess(result); + } else { + completeSaveDialog(request, result, callback); + } + } + + @Override + public boolean isFileDialogFeatureSupported(int feature) { + return feature != com.codename1.io.filechooser.FileDialogFeatures.ACCESSORY_COMPONENT + && feature != com.codename1.io.filechooser.FileDialogFeatures.PREVIEW; + } + @Override public void openImageGallery(com.codename1.ui.events.ActionListener response) { openGallery(response, Display.GALLERY_IMAGE); diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java index ba6395003f2..47c50aab9d2 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java @@ -835,6 +835,17 @@ public static native boolean verifyData(String algorithm, byte[] publicKeyX509, */ public static native String fileDialog(boolean save, int type, String title); + /** + * Shows the rich open/save dialog behind Display.showOpenDialog and + * showSaveDialog. Filters and options are encoded by + * {@link com.codename1.impl.NativeFileDialogCodec}, which also decodes the + * answer; {@code null} means cancelled or headless. Runs on the GTK thread; + * the caller blocks until the user chooses. + */ + public static native String[] fileDialogEx(boolean save, String title, String acceptLabel, + String initialDir, String suggestedName, String[] filters, int selectedFilter, + boolean multi, boolean dirs, boolean confirmOverwrite, boolean showHidden, String[] options); + /* ----------------------------------------------------------- media */ /** Creates a Media Foundation player from the given bytes (spooled to a temp From 9e20255cc0422ca2ff6762e17f96ecfa0fae066a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:25:38 +0300 Subject: [PATCH 05/31] Developer guide: open and save dialogs, feature matrix by platform Co-Authored-By: Claude Opus 5.5 --- .../snippets/generated/IoJava177Snippet.java | 108 ++++++++++++++++++ .../snippets/generated/IoJava178Snippet.java | 108 ++++++++++++++++++ .../snippets/generated/IoJava179Snippet.java | 89 +++++++++++++++ docs/developer-guide/io.asciidoc | 71 ++++++++++++ 4 files changed, 376 insertions(+) create mode 100644 docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/IoJava177Snippet.java create mode 100644 docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/IoJava178Snippet.java create mode 100644 docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/IoJava179Snippet.java diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/IoJava177Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/IoJava177Snippet.java new file mode 100644 index 00000000000..0edca438eec --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/IoJava177Snippet.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.io.filechooser.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + + +class IoJava177Snippet { + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + void sample() { + // tag::io-java-177[] + Display.getInstance().showOpenDialog(new OpenFileRequest() + .title("Import photos") + .multiSelect(true) + .addFilter(new FileTypeFilter("Images") + .extensions("png", "jpg").mimeTypes("image/*")) + .addOption(FileDialogOption.checkbox("strip", "Remove location data", true)) + .preview(path -> { + try (InputStream in = FileSystemStorage.getInstance().openInputStream(path)) { + return new ScaleImageLabel(Image.createImage(in)); + } catch (IOException err) { + return new Label("No preview"); + } + }), + result -> { + if (result.isCancelled()) { + return; + } + boolean strip = "true".equals(result.getOptionValue("strip")); + for (String path : result.getPaths()) { + // import each file, honouring strip + } + }); + // end::io-java-177[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/IoJava178Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/IoJava178Snippet.java new file mode 100644 index 00000000000..6127723a59d --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/IoJava178Snippet.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.io.filechooser.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + + +class IoJava178Snippet { + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + void sample() { + // tag::io-java-178[] + Container formatPanel = BoxLayout.encloseY(new Label("JPEG quality"), new Slider()); + Display.getInstance().showSaveDialog(new SaveFileRequest() + .title("Export") + .suggestedName("Report.pdf") + .addFilter(new FileTypeFilter("PDF document") + .extensions("pdf").mimeTypes("application/pdf").utTypes("com.adobe.pdf")) + .addFilter(new FileTypeFilter("Plain text") + .extensions("txt").mimeTypes("text/plain")) + .addOption(FileDialogOption.choice("pages", "Pages", "All", "Current")) + .accessory(formatPanel) + .hint(FileDialogHints.MAC_SHOWS_TAG_FIELD, true) + .hint(FileDialogHints.WIN_CLIENT_GUID, "6f1c2c7e-9a1b-4c55-8f2a-3e5d1b0a7c11") + .content((result, out) -> { + boolean pdf = result.getSelectedFilterIndex() == 0; + boolean all = "All".equals(result.getOptionValue("pages")); + // write the report to out as PDF or text + }), + result -> { + if (result.getError() != null) { + ToastBar.showErrorMessage("Export failed"); + } + }); + // end::io-java-178[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/IoJava179Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/IoJava179Snippet.java new file mode 100644 index 00000000000..4a00a0d2e33 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/IoJava179Snippet.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.io.filechooser.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + + +class IoJava179Snippet { + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + void sample() { + // tag::io-java-179[] + if (Display.getInstance().isFileDialogFeatureSupported(FileDialogFeatures.PREVIEW)) { + // offer the richer import flow + } + // end::io-java-179[] + } +} diff --git a/docs/developer-guide/io.asciidoc b/docs/developer-guide/io.asciidoc index 77b70984946..37d1d6b31ef 100644 --- a/docs/developer-guide/io.asciidoc +++ b/docs/developer-guide/io.asciidoc @@ -136,6 +136,77 @@ The second argument is a comma-separated accept list. It can contain filename ex No photo-library or media permissions are added by this API. On Android the system document picker grants read access to the selected document. On iOS this API should be implemented with a document picker, not the photo library; `ios.NSPhotoLibraryUsageDescription` is only needed for `openGallery()`. +==== Open and save dialogs + +`openFileChooser()` picks one file. Desktop applications usually need more: a *Save* dialog with a suggested name and a choice of formats, several named filters, extra controls such as "Include charts", and a live preview while the user browses. `Display.showOpenDialog()` and `Display.showSaveDialog()` cover these cases. Each takes a request object from `com.codename1.io.filechooser` and a callback that runs on the EDT, including when the user cancels. + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/IoJava177Snippet.java[tag=io-java-177,indent=0] +---- + +A save request supplies its content after the user has chosen a location. Pass either a `SaveContent` writer, which runs off the EDT and can see the chosen format and option values, or `sourceFile()` to save a copy of an existing file: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/IoJava178Snippet.java[tag=io-java-178,indent=0] +---- + +Every platform can open and save a single file. Some features apply only where the platform has them, and the other platforms ignore them, so one request works everywhere: + +* **Filters** (`FileTypeFilter`) carry extensions, MIME types and Apple type identifiers, and each platform reads the form it understands. Extensions alone work everywhere. +* **Options** (`FileDialogOption`) are declarative checkboxes, choice lists and text fields. Each desktop maps them to its own controls. Where a platform shows none, `FileDialogResult.getOptionValue()` returns the default value. +* **Accessory** components are Codename One components embedded in the dialog. Use them only when options aren't enough, because few platforms can host one. +* **Hints** (`FileDialogHints`) are platform specific settings, such as the macOS tag field, a Windows client GUID or the folder the browser starts in. A platform that doesn't recognize a hint ignores it. + +Call `Display.isFileDialogFeatureSupported()` with a `FileDialogFeatures` constant to check a feature at runtime: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/IoJava179Snippet.java[tag=io-java-179,indent=0] +---- + +.File dialog features by platform +[cols="2,3,3,3",options="header"] +|=== +|Platform |Open |Save |Filters, options, accessory and preview + +|Android +|System document picker, several files, folders +|Storage Access Framework create document. The content streams straight into the document and needs no storage permission. +|One MIME filter. The picker UI is fixed, so it shows no options, accessory or preview. + +|iOS +|Document picker, several files, folders, starting folder +|The content goes to a temporary file, which the document picker then exports +|Type filter. The picker shows its own preview, and options and accessories aren't shown. + +|macOS +|`NSOpenPanel` +|`NSSavePanel` +|Format list, options, accessory component, preview and tag field + +|Windows +|`IFileOpenDialog` +|`IFileSaveDialog` +|Filter list, options as dialog controls and client GUID. No accessory or preview. + +|Linux +|`GtkFileChooser` +|`GtkFileChooser` with overwrite confirmation +|Filter list. Options appear as chooser choices, with text fields in the extra area. No accessory or preview. + +|Simulator and desktop +|The native file dialog, or Swing `JFileChooser` when the request needs more +|Same +|Filter list, options and preview. No accessory. + +|JavaScript +|The browser file picker, several files +|A download with the suggested name and extension +|None +|=== + A more advanced usage of the `FileSystemStorage` API can be a `FileSystemStorage` `Tree`: [source,java] From 587e1259cd804f4ee14dc9fe02c9c520554aa728 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:31:50 +0300 Subject: [PATCH 06/31] File dialogs: satisfy PMD CloseResource and UnnecessaryConstructor Co-Authored-By: Claude Opus 5.5 --- .../src/com/codename1/impl/FileDialogSupport.java | 12 ++++++------ .../codename1/io/filechooser/OpenFileRequest.java | 4 ---- .../codename1/io/filechooser/SaveFileRequest.java | 6 +----- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/FileDialogSupport.java b/CodenameOne/src/com/codename1/impl/FileDialogSupport.java index f85e0b231ba..f21dc7dc8e4 100644 --- a/CodenameOne/src/com/codename1/impl/FileDialogSupport.java +++ b/CodenameOne/src/com/codename1/impl/FileDialogSupport.java @@ -25,7 +25,6 @@ import com.codename1.io.FileSystemStorage; import com.codename1.io.Log; -import com.codename1.io.Util; import com.codename1.io.filechooser.FileDialogOption; import com.codename1.io.filechooser.FileDialogResult; import com.codename1.io.filechooser.FileTypeFilter; @@ -195,15 +194,16 @@ static void completeSave(final SaveFileRequest request, final FileDialogResult r Display.getInstance().scheduleBackgroundTask(new Runnable() { @Override public void run() { - OutputStream out = null; try { - out = FileSystemStorage.getInstance().openOutputStream(result.getPath()); - request.writeContent(result, out); + OutputStream out = FileSystemStorage.getInstance().openOutputStream(result.getPath()); + try { + request.writeContent(result, out); + } finally { + out.close(); + } } catch (IOException err) { Log.e(err); result.setError(err); - } finally { - Util.cleanup(out); } Display.getInstance().callSerially(new Runnable() { @Override diff --git a/CodenameOne/src/com/codename1/io/filechooser/OpenFileRequest.java b/CodenameOne/src/com/codename1/io/filechooser/OpenFileRequest.java index 0885e380513..93973405624 100644 --- a/CodenameOne/src/com/codename1/io/filechooser/OpenFileRequest.java +++ b/CodenameOne/src/com/codename1/io/filechooser/OpenFileRequest.java @@ -49,10 +49,6 @@ public class OpenFileRequest extends FileDialogRequest { private boolean directories; private FilePreviewProvider preview; - /// Creates an Open request with no filter, accepting every file. - public OpenFileRequest() { - } - /// Lets the user pick several files. Platforms without /// `FileDialogFeatures#MULTI_SELECT` return one. /// diff --git a/CodenameOne/src/com/codename1/io/filechooser/SaveFileRequest.java b/CodenameOne/src/com/codename1/io/filechooser/SaveFileRequest.java index 61e1ce3886d..85c0d00d6a8 100644 --- a/CodenameOne/src/com/codename1/io/filechooser/SaveFileRequest.java +++ b/CodenameOne/src/com/codename1/io/filechooser/SaveFileRequest.java @@ -62,10 +62,6 @@ public class SaveFileRequest extends FileDialogRequest { private String sourceFile; private SaveContent content; - /// Creates a Save request. - public SaveFileRequest() { - } - /// Sets the file name the dialog suggests, including its extension. /// /// #### Parameters @@ -183,7 +179,7 @@ public void writeContent(FileDialogResult result, OutputStream out) throws IOExc try { Util.copyNoClose(in, out, 8192); } finally { - Util.cleanup(in); + in.close(); } } } From ff5fc5bb55c0175855d19343f085d25bbc841efb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:39:55 +0300 Subject: [PATCH 07/31] Android: native file open/save dialogs over the Storage Access Framework Co-Authored-By: Claude Opus 5.5 --- .../impl/android/AndroidFileDialogs.java | 387 ++++++++++++++++++ .../impl/android/AndroidImplementation.java | 17 + 2 files changed, 404 insertions(+) create mode 100644 Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java b/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java new file mode 100644 index 00000000000..27d42734a94 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java @@ -0,0 +1,387 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.android; + +import android.app.Activity; +import android.content.ClipData; +import android.content.ContentResolver; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.provider.OpenableColumns; +import android.webkit.MimeTypeMap; +import com.codename1.io.filechooser.FileDialogFeatures; +import com.codename1.io.filechooser.FileDialogHints; +import com.codename1.io.filechooser.FileDialogRequest; +import com.codename1.io.filechooser.FileDialogResult; +import com.codename1.io.filechooser.FileTypeFilter; +import com.codename1.io.filechooser.OpenFileRequest; +import com.codename1.io.filechooser.SaveFileRequest; +import com.codename1.ui.Display; +import com.codename1.util.SuccessCallback; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Storage Access Framework implementation of the native file open/save dialogs. + * Opened documents are copied into the app cache and reported as FileSystemStorage + * paths; a directory pick reports the tree URI; a save writes straight to the + * content URI the system picker created and reports that URI. + */ +final class AndroidFileDialogs { + private static final int REQUEST_CODE = 2101; + + private AndroidFileDialogs() { + } + + static boolean isFeatureSupported(int feature) { + switch (feature) { + case FileDialogFeatures.NATIVE: + case FileDialogFeatures.MULTI_SELECT: + case FileDialogFeatures.SAVE_NAME_EDITING: + return true; + case FileDialogFeatures.DIRECTORIES: + return Build.VERSION.SDK_INT >= 21; + case FileDialogFeatures.INITIAL_DIRECTORY: + // Only through the FileDialogHints.ANDROID_INITIAL_URI hint; SAF has no file paths + return Build.VERSION.SDK_INT >= 26; + default: + return false; + } + } + + static void showOpen(final OpenFileRequest request, final SuccessCallback callback) { + final Intent intent; + if (request.isDirectories()) { + if (Build.VERSION.SDK_INT < 21) { + deliver(callback, FileDialogResult.cancelled(request)); + return; + } + intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + } else { + intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + String[] mimes = mimeTypes(request.getFilters(), -1); + if (mimes.length == 1) { + intent.setType(mimes[0]); + } else { + intent.setType("*/*"); + intent.putExtra(Intent.EXTRA_MIME_TYPES, mimes); + } + if (request.isMultiSelect()) { + intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); + } + } + applyInitialUri(request, intent); + final boolean persist = request.getBooleanHint(FileDialogHints.ANDROID_PERSIST_PERMISSION, false); + start(request, callback, intent, new IntentResultListener() { + public void onActivityResult(int requestCode, int resultCode, Intent data) { + if (resultCode != Activity.RESULT_OK || data == null) { + deliver(callback, FileDialogResult.cancelled(request)); + return; + } + final List uris = new ArrayList(); + ClipData clip = data.getClipData(); + if (clip != null) { + for (int i = 0; i < clip.getItemCount(); i++) { + Uri u = clip.getItemAt(i).getUri(); + if (u != null) { + uris.add(u); + } + } + } + if (uris.isEmpty() && data.getData() != null) { + uris.add(data.getData()); + } + if (uris.isEmpty()) { + deliver(callback, FileDialogResult.cancelled(request)); + return; + } + final ContentResolver resolver = AndroidNativeUtil.getContext().getContentResolver(); + if (persist) { + int flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + | (data.getFlags() & Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + for (Uri u : uris) { + try { + resolver.takePersistableUriPermission(u, flags); + } catch (SecurityException ignored) { + } + } + } + if (request.isDirectories()) { + String[] paths = new String[uris.size()]; + for (int i = 0; i < paths.length; i++) { + paths[i] = uris.get(i).toString(); + } + deliver(callback, new FileDialogResult(request, paths, -1, null)); + return; + } + // Copying can be slow for cloud documents, keep it off the UI thread + new Thread(new Runnable() { + public void run() { + FileDialogResult result; + try { + String[] paths = new String[uris.size()]; + for (int i = 0; i < paths.length; i++) { + paths[i] = copyToCache(resolver, uris.get(i)); + } + result = new FileDialogResult(request, paths, -1, null); + } catch (IOException e) { + result = new FileDialogResult(request, null, -1, null); + result.setError(e); + } catch (SecurityException e) { + result = new FileDialogResult(request, null, -1, null); + result.setError(e); + } + deliver(callback, result); + } + }, "FileDialogCopy").start(); + } + }); + } + + static void showSave(final SaveFileRequest request, final SuccessCallback callback) { + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + String name = request.getSuggestedName(); + String[] mimes = mimeTypes(request.getFilters(), request.getSelectedFilter()); + String type = mimes.length == 1 ? mimes[0] : null; + if (type == null || type.indexOf('*') >= 0) { + String fromName = name == null ? null : mimeForExtension(extensionOf(name)); + if (fromName != null) { + type = fromName; + } else if (type == null) { + type = "application/octet-stream"; + } + } + intent.setType(type); + if (name != null) { + intent.putExtra(Intent.EXTRA_TITLE, name); + } + applyInitialUri(request, intent); + final boolean persist = request.getBooleanHint(FileDialogHints.ANDROID_PERSIST_PERMISSION, false); + start(request, callback, intent, new IntentResultListener() { + public void onActivityResult(int requestCode, int resultCode, Intent data) { + final Uri uri = data == null ? null : data.getData(); + if (resultCode != Activity.RESULT_OK || uri == null) { + deliver(callback, FileDialogResult.cancelled(request)); + return; + } + final ContentResolver resolver = AndroidNativeUtil.getContext().getContentResolver(); + if (persist) { + try { + resolver.takePersistableUriPermission(uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + } catch (SecurityException ignored) { + } + } + final FileDialogResult result = new FileDialogResult(request, new String[]{uri.toString()}, -1, null); + new Thread(new Runnable() { + public void run() { + OutputStream out = null; + try { + out = resolver.openOutputStream(uri); + if (out == null) { + throw new IOException("Cannot open " + uri); + } + request.writeContent(result, out); + } catch (IOException e) { + result.setError(e); + } catch (SecurityException e) { + result.setError(e); + } finally { + if (out != null) { + try { + out.close(); + } catch (IOException e) { + if (result.getError() == null) { + result.setError(e); + } + } + } + } + deliver(callback, result); + } + }, "FileDialogSave").start(); + } + }); + } + + private static void start(FileDialogRequest request, SuccessCallback callback, + Intent intent, IntentResultListener listener) { + if (AndroidNativeUtil.getActivity() == null) { + FileDialogResult r = FileDialogResult.cancelled(request); + r.setError(new IOException("No activity to show the file dialog")); + deliver(callback, r); + return; + } + AndroidNativeUtil.startActivityForResult(intent, REQUEST_CODE, listener); + } + + private static void applyInitialUri(FileDialogRequest request, Intent intent) { + String initial = request.getStringHint(FileDialogHints.ANDROID_INITIAL_URI); + if (initial != null && initial.length() > 0 && Build.VERSION.SDK_INT >= 26) { + intent.putExtra(android.provider.DocumentsContract.EXTRA_INITIAL_URI, Uri.parse(initial)); + } + } + + private static void deliver(final SuccessCallback callback, final FileDialogResult result) { + Display.getInstance().callSerially(new Runnable() { + public void run() { + callback.onSucess(result); + } + }); + } + + /** + * MIME types for the filters, or just the filter at {@code only} when that is a valid index. + * Answers a single wildcard when any filter cannot be expressed as a MIME type, since + * narrowing further would hide files the filter accepts. + */ + private static String[] mimeTypes(FileTypeFilter[] filters, int only) { + Set out = new LinkedHashSet(); + boolean unknown = false; + for (int i = 0; i < filters.length; i++) { + if (only >= 0 && only < filters.length && i != only) { + continue; + } + FileTypeFilter f = filters[i]; + String[] m = f.getMimeTypes(); + if (m != null && m.length > 0) { + for (String s : m) { + out.add(asciiLower(s)); + } + continue; + } + String[] ext = f.getExtensions(); + if (ext == null || ext.length == 0) { + unknown = true; + continue; + } + for (String e : ext) { + String mime = mimeForExtension(e); + if (mime == null) { + unknown = true; + } else { + out.add(mime); + } + } + } + if (unknown || out.isEmpty()) { + return new String[]{"*/*"}; + } + return out.toArray(new String[out.size()]); + } + + private static String mimeForExtension(String ext) { + if (ext == null) { + return null; + } + if (ext.startsWith("*.")) { + ext = ext.substring(2); + } else if (ext.startsWith(".")) { + ext = ext.substring(1); + } + if (ext.length() == 0 || "*".equals(ext)) { + return null; + } + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(asciiLower(ext)); + } + + private static String extensionOf(String name) { + int dot = name.lastIndexOf('.'); + return dot < 0 ? null : name.substring(dot + 1); + } + + // Locale independent fold: String.toLowerCase() breaks extensions on Turkish devices + private static String asciiLower(String s) { + char[] c = s.toCharArray(); + for (int i = 0; i < c.length; i++) { + if (c[i] >= 'A' && c[i] <= 'Z') { + c[i] = (char) (c[i] + ('a' - 'A')); + } + } + return new String(c); + } + + private static String displayName(ContentResolver resolver, Uri uri) { + Cursor cursor = null; + try { + cursor = resolver.query(uri, new String[]{OpenableColumns.DISPLAY_NAME}, null, null, null); + if (cursor != null && cursor.moveToFirst()) { + int idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + if (idx >= 0) { + return cursor.getString(idx); + } + } + } catch (RuntimeException ignored) { + } finally { + if (cursor != null) { + cursor.close(); + } + } + return null; + } + + private static String copyToCache(ContentResolver resolver, Uri uri) throws IOException { + String name = displayName(resolver, uri); + if (name == null || name.length() == 0) { + name = "document"; + } + name = name.replace('/', '_').replace('\\', '_'); + // A directory per pick keeps the display name intact without clobbering an earlier copy + File dir = new File(AndroidNativeUtil.getContext().getCacheDir(), + "filedialog" + File.separator + System.currentTimeMillis() + "_" + Integer.toHexString(uri.hashCode())); + if (!dir.isDirectory() && !dir.mkdirs()) { + throw new IOException("Cannot create " + dir); + } + File dest = new File(dir, name); + InputStream in = resolver.openInputStream(uri); + if (in == null) { + throw new IOException("Cannot open " + uri); + } + OutputStream out = null; + try { + out = new FileOutputStream(dest); + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) > -1) { + out.write(buffer, 0, read); + } + } finally { + in.close(); + if (out != null) { + out.close(); + } + } + return "file://" + dest.getAbsolutePath(); + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 0e8fd939634..3edbb6a54de 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -13901,6 +13901,23 @@ public void openGallery(final ActionListener response, int type){ this.getActivity().startActivityForResult(galleryIntent, multi ? OPEN_GALLERY_MULTI: OPEN_GALLERY); } + @Override + public void showOpenDialog(com.codename1.io.filechooser.OpenFileRequest request, + SuccessCallback callback) { + AndroidFileDialogs.showOpen(request, callback); + } + + @Override + public void showSaveDialog(com.codename1.io.filechooser.SaveFileRequest request, + SuccessCallback callback) { + AndroidFileDialogs.showSave(request, callback); + } + + @Override + public boolean isFileDialogFeatureSupported(int feature) { + return AndroidFileDialogs.isFeatureSupported(feature); + } + @Override public void openFileChooser(final ActionListener response, String accept) { if (getActivity() == null) { From 2e22a099d5e9d40ad2c80e9320e7bca7fcf7b382 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:40:50 +0300 Subject: [PATCH 08/31] iOS: native file open/save dialogs via UIDocumentPickerViewController Open uses initForOpeningContentTypes (filter UTTypes, extensions, folder), multi-select, directoryURL and shouldShowFileExtensions; picked items are copied to tmp. Save stages content in the caches dir off the EDT and exports it with initForExportingURLs asCopy. Results return through a per-request id and are delivered on the EDT. Co-Authored-By: Claude Opus 5.5 --- Ports/iOSPort/nativeSources/IOSNative.m | 242 ++++++++++++++++++ .../codename1/impl/ios/IOSImplementation.java | 189 ++++++++++++++ .../src/com/codename1/impl/ios/IOSNative.java | 2 + 3 files changed, 433 insertions(+) diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index eec3a156203..936f3568b2b 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -9192,6 +9192,248 @@ void com_codename1_impl_ios_IOSNative_openFileChooserWithId___java_lang_String_i #endif } +// ---- FileDialogRequest support (showOpenDialog / showSaveDialog) ---------- +// Each dialog gets its own delegate carrying the Java request id, so it never +// shares the single-slot fileChooserResult path openFileChooser uses. The +// controller's delegate property is weak; the delegate is kept alive by an +// associated-object reference on the controller it serves. +#if !TARGET_OS_OSX && !TARGET_OS_WATCH && !TARGET_OS_TV +#import +#if __has_include() +#import +#define CN1_FILE_DIALOG_HAS_UTTYPE 1 +#endif + +static char cn1FileDialogDelegateKey; + +static void cn1FileDialogDeliver(NSString *paths, JAVA_INT requestId) { + struct ThreadLocalData *threadStateData = getThreadLocalData(); + com_codename1_impl_ios_IOSImplementation_fileDialogResult___java_lang_String_int( + threadStateData, paths == nil ? JAVA_NULL : fromNSString(threadStateData, paths), requestId); +} + +// Copies a picked item (file or folder) into the app's tmp directory, the way +// the single-file chooser does, so the app can read it after the +// security-scoped access ends. +static NSString *cn1FileDialogCopyToTemp(NSURL *url) { + if (url == nil) { + return nil; + } + BOOL securityScoped = [url startAccessingSecurityScopedResource]; + NSString *result = nil; + @try { + NSString *fileName = [url lastPathComponent]; + if (fileName == nil || [fileName length] == 0) { + fileName = @"selected-file"; + } + NSString *folder = [NSTemporaryDirectory() stringByAppendingPathComponent:[[NSUUID UUID] UUIDString]]; + NSFileManager *fileManager = [NSFileManager defaultManager]; + [fileManager createDirectoryAtPath:folder withIntermediateDirectories:YES attributes:nil error:nil]; + NSString *destinationPath = [folder stringByAppendingPathComponent:fileName]; + NSURL *destinationURL = [NSURL fileURLWithPath:destinationPath]; + NSError *error = nil; + if ([url isFileURL] && [fileManager copyItemAtURL:url toURL:destinationURL error:&error]) { + result = destinationPath; + } else { + NSData *data = [NSData dataWithContentsOfURL:url options:0 error:&error]; + if (data != nil && [data writeToURL:destinationURL options:NSDataWritingAtomic error:&error]) { + result = destinationPath; + } else { + NSLog(@"Codename One file dialog failed to copy selected item: %@", error); + } + } + } @finally { + if (securityScoped) { + [url stopAccessingSecurityScopedResource]; + } + } + return result; +} + +@interface CN1FileDialogDelegate : NSObject +@property (nonatomic, assign) JAVA_INT requestId; +@property (nonatomic, assign) BOOL exporting; +@property (nonatomic, assign) BOOL delivered; +@end + +@implementation CN1FileDialogDelegate +- (void)finish:(UIDocumentPickerViewController *)controller paths:(NSString *)paths { + if (!self.delivered) { + self.delivered = YES; + cn1FileDialogDeliver(paths, self.requestId); + } + if (controller.presentingViewController != nil) { + [controller dismissViewControllerAnimated:YES completion:nil]; + } +} + +- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray *)urls { + NSMutableArray *paths = [NSMutableArray array]; + for (NSURL *url in urls) { + // An export reports where the file went; it is not copied back. + NSString *p = self.exporting ? [url path] : cn1FileDialogCopyToTemp(url); + if (p != nil) { + [paths addObject:p]; + } + } + [self finish:controller paths:[paths count] == 0 ? nil : [paths componentsJoinedByString:@"\n"]]; +} + +- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentAtURL:(NSURL *)url { + [self documentPicker:controller didPickDocumentsAtURLs:url == nil ? @[] : @[url]]; +} + +- (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller { + [self finish:controller paths:nil]; +} +@end + +static void cn1FileDialogPresent(UIDocumentPickerViewController *picker, JAVA_INT requestId, BOOL exporting, NSString *initialDirectory) { +#ifndef CN1_USE_ARC + CN1FileDialogDelegate *delegate = [[[CN1FileDialogDelegate alloc] init] autorelease]; +#else + CN1FileDialogDelegate *delegate = [[CN1FileDialogDelegate alloc] init]; +#endif + delegate.requestId = requestId; + delegate.exporting = exporting; + objc_setAssociatedObject(picker, &cn1FileDialogDelegateKey, delegate, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + picker.delegate = delegate; + if (initialDirectory != nil && [initialDirectory length] > 0) { + if (@available(iOS 13.0, *)) { + picker.directoryURL = [NSURL fileURLWithPath:initialDirectory isDirectory:YES]; + } + } + if (popoverSupported()) { + picker.modalPresentationStyle = UIModalPresentationFormSheet; + } + [cn1PresentingController() presentViewController:picker animated:YES completion:nil]; +} + +static NSArray *cn1FileDialogSplit(NSString *joined) { + NSMutableArray *out = [NSMutableArray array]; + if (joined == nil) { + return out; + } + for (NSString *token in [joined componentsSeparatedByString:@"\n"]) { + NSString *t = [token stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + if ([t length] > 0) { + [out addObject:t]; + } + } + return out; +} +#endif + +void com_codename1_impl_ios_IOSNative_showOpenFileDialog___java_lang_String_java_lang_String_boolean_boolean_java_lang_String_boolean_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT utTypes, JAVA_OBJECT extensions, JAVA_BOOLEAN multiSelect, JAVA_BOOLEAN directories, JAVA_OBJECT initialDirectory, JAVA_BOOLEAN showExtensions, JAVA_INT requestId) { +#if !TARGET_OS_OSX && !TARGET_OS_WATCH && !TARGET_OS_TV + NSArray *utList = cn1FileDialogSplit(utTypes == JAVA_NULL ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG utTypes)); + NSArray *extList = cn1FileDialogSplit(extensions == JAVA_NULL ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG extensions)); + NSString *initialDir = initialDirectory == JAVA_NULL ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG initialDirectory); +#ifndef CN1_USE_ARC + [utList retain]; + [extList retain]; + [initialDir retain]; +#endif + dispatch_async(dispatch_get_main_queue(), ^{ + POOL_BEGIN(); + UIDocumentPickerViewController *picker = nil; +#ifdef CN1_FILE_DIALOG_HAS_UTTYPE + if (@available(iOS 14.0, *)) { + NSMutableArray *types = [NSMutableArray array]; + if (directories) { + [types addObject:UTTypeFolder]; + } else { + for (NSString *identifier in utList) { + UTType *t = [UTType typeWithIdentifier:identifier]; + if (t != nil) [types addObject:t]; + } + for (NSString *ext in extList) { + UTType *t = [UTType typeWithFilenameExtension:ext]; + if (t != nil) [types addObject:t]; + } + if ([types count] == 0) { + [types addObject:UTTypeItem]; + } + } + // asCopy:YES imports a copy we own; the folder case needs the + // original, which is then copied below like any file. + picker = [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:types asCopy:!directories]; +#ifndef CN1_USE_ARC + [picker autorelease]; +#endif + } +#endif + if (picker == nil) { + NSMutableArray *legacy = [NSMutableArray array]; + if (directories) { + [legacy addObject:(NSString *)kUTTypeFolder]; + } else { + [legacy addObjectsFromArray:utList]; + if ([legacy count] == 0) { + [legacy addObject:(NSString *)kUTTypeItem]; + } + } + picker = [[UIDocumentPickerViewController alloc] initWithDocumentTypes:legacy + inMode:(directories ? UIDocumentPickerModeOpen : UIDocumentPickerModeImport)]; +#ifndef CN1_USE_ARC + [picker autorelease]; +#endif + } + if (@available(iOS 11.0, *)) { + picker.allowsMultipleSelection = multiSelect ? YES : NO; + } + if (@available(iOS 13.0, *)) { + picker.shouldShowFileExtensions = showExtensions ? YES : NO; + } + cn1FileDialogPresent(picker, requestId, NO, initialDir); +#ifndef CN1_USE_ARC + [utList release]; + [extList release]; + [initialDir release]; +#endif + POOL_END(); + }); +#else + com_codename1_impl_ios_IOSImplementation_fileDialogResult___java_lang_String_int(CN1_THREAD_STATE_PASS_ARG JAVA_NULL, requestId); +#endif +} + +void com_codename1_impl_ios_IOSNative_showSaveFileDialog___java_lang_String_boolean_java_lang_String_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT path, JAVA_BOOLEAN asCopy, JAVA_OBJECT initialDirectory, JAVA_INT requestId) { +#if !TARGET_OS_OSX && !TARGET_OS_WATCH && !TARGET_OS_TV + NSString *filePath = path == JAVA_NULL ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG path); + NSString *initialDir = initialDirectory == JAVA_NULL ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG initialDirectory); + if (filePath == nil) { + com_codename1_impl_ios_IOSImplementation_fileDialogResult___java_lang_String_int(CN1_THREAD_STATE_PASS_ARG JAVA_NULL, requestId); + return; + } +#ifndef CN1_USE_ARC + [filePath retain]; + [initialDir retain]; +#endif + dispatch_async(dispatch_get_main_queue(), ^{ + POOL_BEGIN(); + NSURL *url = [NSURL fileURLWithPath:filePath]; + UIDocumentPickerViewController *picker = nil; + if (@available(iOS 14.0, *)) { + picker = [[UIDocumentPickerViewController alloc] initForExportingURLs:@[url] asCopy:(asCopy ? YES : NO)]; + } else { + picker = [[UIDocumentPickerViewController alloc] initWithURL:url inMode:UIDocumentPickerModeExportToService]; + } +#ifndef CN1_USE_ARC + [picker autorelease]; +#endif + cn1FileDialogPresent(picker, requestId, YES, initialDir); +#ifndef CN1_USE_ARC + [filePath release]; + [initialDir release]; +#endif + POOL_END(); + }); +#else + com_codename1_impl_ios_IOSImplementation_fileDialogResult___java_lang_String_int(CN1_THREAD_STATE_PASS_ARG JAVA_NULL, requestId); +#endif +} + #ifdef INCLUDE_PHOTOLIBRARY_USAGE #ifdef ENABLE_GALLERY_MULTISELECT diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 33a1fcf0333..b7cdfbd31ee 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -6767,6 +6767,195 @@ public void openFileChooser(ActionListener response, String accept) { nativeInstance.openFileChooser(accept); } } + + /// Pending native file dialogs, keyed by the id handed to the native side. + /// Reached from the EDT, the save writer task and the native completion + /// (UIKit main thread), so it follows the macFileChooserRequests discipline. + private static final java.util.HashMap fileDialogRequests = + new java.util.HashMap(); + private static int fileDialogSequence; + + private static final class PendingFileDialog { + final com.codename1.io.filechooser.FileDialogRequest request; + final SuccessCallback callback; + final String tempFile; + + PendingFileDialog(com.codename1.io.filechooser.FileDialogRequest request, + SuccessCallback callback, + String tempFile) { + this.request = request; + this.callback = callback; + this.tempFile = tempFile; + } + } + + private static int registerFileDialog(PendingFileDialog pending) { + synchronized (fileDialogRequests) { + int id = ++fileDialogSequence; + fileDialogRequests.put(id, pending); + return id; + } + } + + private static String joinLines(java.util.List values) { + StringBuilder sb = new StringBuilder(); + for (String v : values) { + if (v == null || v.length() == 0) { + continue; + } + if (sb.length() > 0) { + sb.append('\n'); + } + sb.append(v); + } + return sb.length() == 0 ? null : sb.toString(); + } + + private static String stripFileScheme(String path) { + if (path != null && path.startsWith("file://")) { + return path.substring(7); + } + return path; + } + + @Override + public boolean isFileDialogFeatureSupported(int feature) { + if (isMacPlatform()) { + return super.isFileDialogFeatureSupported(feature); + } + return feature == com.codename1.io.filechooser.FileDialogFeatures.NATIVE + || feature == com.codename1.io.filechooser.FileDialogFeatures.MULTI_SELECT + || feature == com.codename1.io.filechooser.FileDialogFeatures.DIRECTORIES + || feature == com.codename1.io.filechooser.FileDialogFeatures.INITIAL_DIRECTORY; + } + + @Override + public void showOpenDialog(com.codename1.io.filechooser.OpenFileRequest request, + SuccessCallback callback) { + if (isMacPlatform()) { + // The Mac port answers through its NSOpenPanel chooser. + super.showOpenDialog(request, callback); + return; + } + java.util.ArrayList uts = new java.util.ArrayList(); + java.util.ArrayList exts = new java.util.ArrayList(); + for (com.codename1.io.filechooser.FileTypeFilter f : request.getFilters()) { + String[] u = f.getUtTypes(); + if (u != null && u.length > 0) { + for (String s : u) { + uts.add(s); + } + continue; + } + String[] e = f.getExtensions(); + if (e != null) { + for (String s : e) { + if (s != null && s.startsWith(".")) { + s = s.substring(1); + } + // Not case folded: UTType resolves extensions case + // insensitively, and toLowerCase is locale sensitive. + exts.add(s); + } + } + } + int id = registerFileDialog(new PendingFileDialog(request, callback, null)); + nativeInstance.showOpenFileDialog(joinLines(uts), joinLines(exts), + request.isMultiSelect(), request.isDirectories(), + stripFileScheme(request.getInitialDirectory()), + request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.IOS_SHOWS_FILE_EXTENSIONS, false), + id); + } + + @Override + public void showSaveDialog(final com.codename1.io.filechooser.SaveFileRequest request, + final SuccessCallback callback) { + if (isMacPlatform()) { + super.showSaveDialog(request, callback); + return; + } + String name = request.getSuggestedName(); + if (name == null || name.length() == 0) { + name = "Untitled"; + } + // A separator in the suggested name would escape the staging folder. + name = name.replace('/', '_'); + String caches = nativeInstance.getCachesDir(); + if (!caches.endsWith("/")) { + caches = caches + "/"; + } + final String dir = caches + "cn1-export-" + System.currentTimeMillis() + "/"; + final String temp = dir + name; + // The document picker exports a file that already exists, so the + // content is staged first, off the EDT. + Display.getInstance().scheduleBackgroundTask(new Runnable() { + @Override + public void run() { + com.codename1.io.FileSystemStorage fs = com.codename1.io.FileSystemStorage.getInstance(); + java.io.OutputStream out = null; + try { + fs.mkdir(dir); + out = fs.openOutputStream(temp); + request.writeContent(new com.codename1.io.filechooser.FileDialogResult( + request, new String[]{temp}, -1, null), out); + out.close(); + out = null; + } catch (final IOException err) { + com.codename1.io.Util.cleanup(out); + fs.delete(temp); + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + com.codename1.io.filechooser.FileDialogResult r = + com.codename1.io.filechooser.FileDialogResult.cancelled(request); + r.setError(err); + callback.onSucess(r); + } + }); + return; + } + int id = registerFileDialog(new PendingFileDialog(request, callback, temp)); + nativeInstance.showSaveFileDialog(temp, + !request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.IOS_EXPORT_AS_MOVE, false), + stripFileScheme(request.getInitialDirectory()), id); + } + }); + } + + /// Native completion of showOpenFileDialog and showSaveFileDialog. `paths` + /// is newline separated, null when cancelled. Called on the UIKit main + /// thread; the callback is delivered on the EDT. + public static void fileDialogResult(final String paths, final int requestId) { + final PendingFileDialog pending; + synchronized (fileDialogRequests) { + pending = fileDialogRequests.remove(requestId); + } + if (pending == null) { + return; // Duplicate or unknown completion. + } + Runnable delivery = new Runnable() { + @Override + public void run() { + com.codename1.io.filechooser.FileDialogResult r; + if (paths == null || paths.length() == 0) { + r = com.codename1.io.filechooser.FileDialogResult.cancelled(pending.request); + } else { + java.util.List list = StringUtil.tokenize(paths, '\n'); + r = new com.codename1.io.filechooser.FileDialogResult(pending.request, + list.toArray(new String[list.size()]), -1, null); + } + if (pending.tempFile != null) { + // Exported as a copy or moved away; either way the staging + // file is no longer needed. + com.codename1.io.FileSystemStorage.getInstance().delete(pending.tempFile); + } + pending.callback.onSucess(r); + } + }; + if (!deliverPickerResultOnEdt(delivery)) { + delivery.run(); + } + } diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index e19bb335f74..6b3c9178a10 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -699,6 +699,8 @@ native void prepareNativeDrag(String mimeTypes, int allowedActions, byte[] dragI native void openGallery(int type); native void openFileChooser(String accept); native void openFileChooserWithId(String accept, int requestId); + native void showOpenFileDialog(String utTypes, String extensions, boolean multiSelect, boolean directories, String initialDirectory, boolean showExtensions, int requestId); + native void showSaveFileDialog(String path, boolean asCopy, String initialDirectory, int requestId); // Low-level camera API (com.codename1.camera). Backed by CN1Camera.m // which wraps AVCaptureSession. The IOSCameraImpl class on the Java side From 0375e5a657ecc4dea8fc68353cf310c7fbd9b0ab Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:43:23 +0300 Subject: [PATCH 09/31] Android file dialogs: close both streams when copying a picked document Co-Authored-By: Claude Opus 5.5 --- .../impl/android/AndroidFileDialogs.java | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java b/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java index 27d42734a94..78fc108f8db 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java @@ -368,19 +368,19 @@ private static String copyToCache(ContentResolver resolver, Uri uri) throws IOEx if (in == null) { throw new IOException("Cannot open " + uri); } - OutputStream out = null; try { - out = new FileOutputStream(dest); - byte[] buffer = new byte[8192]; - int read; - while ((read = in.read(buffer)) > -1) { - out.write(buffer, 0, read); + OutputStream out = new FileOutputStream(dest); + try { + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) > -1) { + out.write(buffer, 0, read); + } + } finally { + out.close(); } } finally { in.close(); - if (out != null) { - out.close(); - } } return "file://" + dest.getAbsolutePath(); } From 24d72f7994ef87a30a7c72bce55b2414ec42ead3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:45:07 +0300 Subject: [PATCH 10/31] Windows port: IFileOpenDialog/IFileSaveDialog open/save dialogs with filters, options and hints Co-Authored-By: Claude Opus 5.5 --- Ports/WindowsPort/nativeSources/cn1_windows.h | 4 + .../nativeSources/cn1_windows_filedialog.cpp | 578 ++++++++++++++++++ .../nativeSources/cn1_windows_window.cpp | 3 + .../impl/windows/WindowsImplementation.java | 45 ++ .../codename1/impl/windows/WindowsNative.java | 13 + 5 files changed, 643 insertions(+) create mode 100644 Ports/WindowsPort/nativeSources/cn1_windows_filedialog.cpp diff --git a/Ports/WindowsPort/nativeSources/cn1_windows.h b/Ports/WindowsPort/nativeSources/cn1_windows.h index 90216898f93..780326e667f 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows.h +++ b/Ports/WindowsPort/nativeSources/cn1_windows.h @@ -359,6 +359,10 @@ HBRUSH cn1WinEditCtlColor(HDC hdc, HWND control); * that returns once the user has chosen -- and cn1WinWndProc forwards it here. */ #define WM_CN1_FILEDIALOG (WM_APP + 19) LRESULT cn1WinFileDialogHandleMessage(WPARAM wParam); +/* Rich IFileOpenDialog/IFileSaveDialog behind Display.showOpenDialog and + * showSaveDialog (cn1_windows_filedialog.cpp), marshaled the same way. */ +#define WM_CN1_FILEDIALOGEX (WM_APP + 29) +LRESULT cn1WinFileDialogExHandleMessage(WPARAM wParam); /* Local notifications via Shell_NotifyIcon (cn1_windows_notify.c). A Timer on a * worker thread posts WM_CN1_NOTIFY to the pump thread (which owns the tray icon) diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_filedialog.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_filedialog.cpp new file mode 100644 index 00000000000..afd33ee8328 --- /dev/null +++ b/Ports/WindowsPort/nativeSources/cn1_windows_filedialog.cpp @@ -0,0 +1,578 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +/* + * Rich open/save dialog behind Display.showOpenDialog / showSaveDialog, over + * the Vista+ COM IFileOpenDialog / IFileSaveDialog. The string contract (filters, + * options and the answer array) is com.codename1.impl.NativeFileDialogCodec. + * + * Threading: the dialog is modal and must be owned by the pump thread, so the + * EDT-facing native copies every Java string into malloc'd wide memory, yields + * its thread state, SendMessage's WM_CN1_FILEDIALOGEX to cn1Win.hwnd and builds + * the Java String[] only after it has resumed. Nothing on the pump thread touches + * a Java object. + */ +#ifdef _WIN32 + +#include "cn1_windows.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +/* IFileDialogCustomize ids: control i is 100 + i, its visual group 5000 + i. */ +const DWORD CN1_FDX_CONTROL_BASE = 100; +const DWORD CN1_FDX_GROUP_BASE = 5000; + +struct Cn1FdxSplit { + WCHAR* buf; /* owned copy, '\n' replaced by NUL */ + WCHAR** fields; /* pointers into buf */ + int count; +}; + +struct Cn1FdxRequest { + int save; + int multi; + int dirs; + int confirmOverwrite; + int showHidden; + int selectedFilter; + WCHAR* title; + WCHAR* acceptLabel; + WCHAR* initialDir; + WCHAR* suggestedName; + WCHAR* clientGuid; + WCHAR* defaultFolder; + int filterCount; + Cn1FdxSplit* filters; + int optionCount; + Cn1FdxSplit* options; + + /* answer, written on the pump thread */ + int accepted; + int chosenFilter; + WCHAR** values; /* optionCount entries, CoTaskMem or malloc (see valueIsCoTask) */ + int* valueIsCoTask; + WCHAR** paths; /* CoTaskMem strings */ + int pathCount; +}; + +WCHAR* cn1FdxDup(const WCHAR* s) { + size_t n; + WCHAR* out; + if (s == NULL) { + return NULL; + } + n = wcslen(s); + out = (WCHAR*) malloc((n + 1) * sizeof(WCHAR)); + if (out != NULL) { + memcpy(out, s, (n + 1) * sizeof(WCHAR)); + } + return out; +} + +/* Java path (possibly "/C:/dir" from a file:// URL) to a Windows path, in place. */ +void cn1FdxNormalizePath(WCHAR* p) { + size_t i; + if (p == NULL) { + return; + } + if (p[0] == L'/' && p[1] != 0 && p[2] == L':') { + memmove(p, p + 1, wcslen(p) * sizeof(WCHAR)); + } + for (i = 0; p[i] != 0; i++) { + if (p[i] == L'/') { + p[i] = L'\\'; + } + } +} + +void cn1FdxSplitInto(WCHAR* buf, Cn1FdxSplit* out) { + int n = 1; + int i; + WCHAR* c; + out->buf = buf; + out->fields = NULL; + out->count = 0; + if (buf == NULL) { + return; + } + for (c = buf; *c != 0; c++) { + if (*c == L'\n') { + n++; + } + } + out->fields = (WCHAR**) calloc((size_t) n, sizeof(WCHAR*)); + if (out->fields == NULL) { + return; + } + out->fields[0] = buf; + i = 1; + for (c = buf; *c != 0; c++) { + if (*c == L'\n') { + *c = 0; + out->fields[i++] = c + 1; + } + } + out->count = n; +} + +/* Reads a Java String[] into split wide strings. Called before yielding. */ +Cn1FdxSplit* cn1FdxReadArray(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT arr, int* count) { + int n, i; + JAVA_OBJECT* elems; + Cn1FdxSplit* out; + *count = 0; + if (arr == JAVA_NULL) { + return NULL; + } + n = (int) (*(JAVA_ARRAY) arr).length; + if (n <= 0) { + return NULL; + } + out = (Cn1FdxSplit*) calloc((size_t) n, sizeof(Cn1FdxSplit)); + if (out == NULL) { + return NULL; + } + elems = (JAVA_OBJECT*) CN1_ARRAY_DATA(arr); + for (i = 0; i < n; i++) { + WCHAR* w = elems[i] != JAVA_NULL ? cn1WinJavaStringToWide(threadStateData, elems[i], NULL) : NULL; + cn1FdxSplitInto(w, &out[i]); + } + *count = n; + return out; +} + +void cn1FdxFreeArray(Cn1FdxSplit* a, int n) { + int i; + if (a == NULL) { + return; + } + for (i = 0; i < n; i++) { + free(a[i].fields); + free(a[i].buf); + } + free(a); +} + +WCHAR* cn1FdxReadString(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT s) { + return s != JAVA_NULL ? cn1WinJavaStringToWide(threadStateData, s, NULL) : NULL; +} + +IShellItem* cn1FdxItem(const WCHAR* path) { + IShellItem* item = NULL; + if (path == NULL || path[0] == 0) { + return NULL; + } + if (FAILED(SHCreateItemFromParsingName(path, NULL, IID_IShellItem, (void**) &item))) { + return NULL; + } + return item; +} + +/* Builds the options panel through IFileDialogCustomize. */ +void cn1FdxAddOptions(IFileDialog* dlg, Cn1FdxRequest* r) { + IFileDialogCustomize* cust = NULL; + int i, k; + if (r->optionCount <= 0 + || FAILED(dlg->QueryInterface(IID_IFileDialogCustomize, (void**) &cust))) { + return; + } + for (i = 0; i < r->optionCount; i++) { + Cn1FdxSplit* o = &r->options[i]; + DWORD id = CN1_FDX_CONTROL_BASE + (DWORD) i; + DWORD group = CN1_FDX_GROUP_BASE + (DWORD) i; + if (o->count < 4) { + continue; + } + if (o->fields[0][0] == L'1') { + cust->AddCheckButton(id, o->fields[2], _wcsicmp(o->fields[3], L"true") == 0 ? TRUE : FALSE); + } else if (o->fields[0][0] == L'2') { + cust->StartVisualGroup(group, o->fields[2]); + cust->AddComboBox(id); + for (k = 4; k < o->count; k++) { + cust->AddControlItem(id, (DWORD) (k - 4), o->fields[k]); + if (wcscmp(o->fields[k], o->fields[3]) == 0) { + cust->SetSelectedControlItem(id, (DWORD) (k - 4)); + } + } + cust->EndVisualGroup(); + } else { + cust->StartVisualGroup(group, o->fields[2]); + cust->AddEditBox(id, o->fields[3]); + cust->EndVisualGroup(); + } + } + cust->Release(); +} + +void cn1FdxReadOptions(IFileDialog* dlg, Cn1FdxRequest* r) { + IFileDialogCustomize* cust = NULL; + int i; + if (r->optionCount <= 0) { + return; + } + r->values = (WCHAR**) calloc((size_t) r->optionCount, sizeof(WCHAR*)); + r->valueIsCoTask = (int*) calloc((size_t) r->optionCount, sizeof(int)); + if (r->values == NULL || r->valueIsCoTask == NULL + || FAILED(dlg->QueryInterface(IID_IFileDialogCustomize, (void**) &cust))) { + return; + } + for (i = 0; i < r->optionCount; i++) { + Cn1FdxSplit* o = &r->options[i]; + DWORD id = CN1_FDX_CONTROL_BASE + (DWORD) i; + if (o->count < 4) { + continue; + } + if (o->fields[0][0] == L'1') { + BOOL checked = FALSE; + if (SUCCEEDED(cust->GetCheckButtonState(id, &checked))) { + r->values[i] = cn1FdxDup(checked ? L"true" : L"false"); + } + } else if (o->fields[0][0] == L'2') { + DWORD sel = 0; + if (SUCCEEDED(cust->GetSelectedControlItem(id, &sel)) && (int) sel + 4 < o->count) { + r->values[i] = cn1FdxDup(o->fields[sel + 4]); + } + } else { + WCHAR* text = NULL; + if (SUCCEEDED(cust->GetEditBoxText(id, &text)) && text != NULL) { + r->values[i] = text; + r->valueIsCoTask[i] = 1; + } + } + } + cust->Release(); +} + +void cn1FdxAddPath(Cn1FdxRequest* r, IShellItem* item) { + WCHAR* path = NULL; + WCHAR** grown; + if (item == NULL || FAILED(item->GetDisplayName(SIGDN_FILESYSPATH, &path)) || path == NULL) { + return; + } + grown = (WCHAR**) realloc(r->paths, (size_t) (r->pathCount + 1) * sizeof(WCHAR*)); + if (grown == NULL) { + CoTaskMemFree(path); + return; + } + r->paths = grown; + r->paths[r->pathCount++] = path; +} + +void cn1FdxRun(Cn1FdxRequest* r) { + IFileDialog* dlg = NULL; + COMDLG_FILTERSPEC* specs = NULL; + WCHAR** specText = NULL; + FILEOPENDIALOGOPTIONS opts = 0; + IShellItem* item; + int i, j; + HRESULT hr; + + hr = CoCreateInstance(r->save ? CLSID_FileSaveDialog : CLSID_FileOpenDialog, NULL, + CLSCTX_INPROC_SERVER, r->save ? IID_IFileSaveDialog : IID_IFileOpenDialog, (void**) &dlg); + if (FAILED(hr) || dlg == NULL) { + return; + } + if (r->clientGuid != NULL && r->clientGuid[0] != 0) { + /* CLSIDFromString wants the braced form; accept a bare GUID too. */ + WCHAR braced[64]; + GUID guid; + if (r->clientGuid[0] == L'{') { + _snwprintf(braced, 63, L"%ls", r->clientGuid); + } else { + _snwprintf(braced, 63, L"{%ls}", r->clientGuid); + } + braced[63] = 0; + if (SUCCEEDED(CLSIDFromString(braced, &guid))) { + dlg->SetClientGuid(guid); + } + } + dlg->GetOptions(&opts); + opts |= FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR; + if (r->showHidden) { + opts |= FOS_FORCESHOWHIDDEN; + } + if (r->save) { + if (r->confirmOverwrite) { + opts |= FOS_OVERWRITEPROMPT; + } else { + opts &= ~FOS_OVERWRITEPROMPT; + } + } else { + opts |= FOS_PATHMUSTEXIST; + if (r->dirs) { + opts |= FOS_PICKFOLDERS; + } else { + opts |= FOS_FILEMUSTEXIST; + } + if (r->multi) { + opts |= FOS_ALLOWMULTISELECT; + } + } + dlg->SetOptions(opts); + if (r->title != NULL) { + dlg->SetTitle(r->title); + } + if (r->acceptLabel != NULL && r->acceptLabel[0] != 0) { + dlg->SetOkButtonLabel(r->acceptLabel); + } + if (r->filterCount > 0 && !r->dirs) { + specs = (COMDLG_FILTERSPEC*) calloc((size_t) r->filterCount, sizeof(COMDLG_FILTERSPEC)); + specText = (WCHAR**) calloc((size_t) r->filterCount, sizeof(WCHAR*)); + if (specs != NULL && specText != NULL) { + for (i = 0; i < r->filterCount; i++) { + Cn1FdxSplit* f = &r->filters[i]; + size_t len = 4; + for (j = 1; j < f->count; j++) { + len += wcslen(f->fields[j]) + 3; + } + specText[i] = (WCHAR*) calloc(len, sizeof(WCHAR)); + if (specText[i] == NULL) { + continue; + } + if (f->count <= 1) { + wcscpy(specText[i], L"*.*"); + } else { + for (j = 1; j < f->count; j++) { + if (j > 1) { + wcscat(specText[i], L";"); + } + wcscat(specText[i], L"*."); + wcscat(specText[i], f->fields[j]); + } + } + specs[i].pszName = f->count > 0 ? f->fields[0] : L""; + specs[i].pszSpec = specText[i]; + } + dlg->SetFileTypes((UINT) r->filterCount, specs); + if (r->selectedFilter >= 0 && r->selectedFilter < r->filterCount) { + Cn1FdxSplit* f = &r->filters[r->selectedFilter]; + dlg->SetFileTypeIndex((UINT) r->selectedFilter + 1); /* 1-based */ + if (r->save && f->count > 1) { + dlg->SetDefaultExtension(f->fields[1]); + } + } + } + } + item = cn1FdxItem(r->defaultFolder); + if (item != NULL) { + dlg->SetDefaultFolder(item); + item->Release(); + } + item = cn1FdxItem(r->initialDir); + if (item != NULL) { + dlg->SetFolder(item); + item->Release(); + } + if (r->suggestedName != NULL && r->suggestedName[0] != 0) { + dlg->SetFileName(r->suggestedName); + } + cn1FdxAddOptions(dlg, r); + + if (SUCCEEDED(dlg->Show(cn1Win.hwnd))) { + UINT typeIndex = 0; + r->accepted = 1; + r->chosenFilter = -1; + if (r->filterCount > 0 && !r->dirs && SUCCEEDED(dlg->GetFileTypeIndex(&typeIndex)) + && typeIndex >= 1 && (int) typeIndex <= r->filterCount) { + r->chosenFilter = (int) typeIndex - 1; + } + cn1FdxReadOptions(dlg, r); + if (r->save) { + item = NULL; + if (SUCCEEDED(dlg->GetResult(&item)) && item != NULL) { + cn1FdxAddPath(r, item); + item->Release(); + } + } else { + IShellItemArray* items = NULL; + if (SUCCEEDED(((IFileOpenDialog*) dlg)->GetResults(&items)) && items != NULL) { + DWORD n = 0, k; + items->GetCount(&n); + for (k = 0; k < n; k++) { + item = NULL; + if (SUCCEEDED(items->GetItemAt(k, &item)) && item != NULL) { + cn1FdxAddPath(r, item); + item->Release(); + } + } + items->Release(); + } + } + } + dlg->Release(); + if (specText != NULL) { + for (i = 0; i < r->filterCount; i++) { + free(specText[i]); + } + free(specText); + } + free(specs); +} + +JAVA_OBJECT cn1FdxToJava(CODENAME_ONE_THREAD_STATE, const WCHAR* wide) { + int needed; + char* utf8; + JAVA_OBJECT result; + if (wide == NULL) { + return JAVA_NULL; + } + needed = WideCharToMultiByte(CP_UTF8, 0, wide, -1, NULL, 0, NULL, NULL); + if (needed <= 0) { + return JAVA_NULL; + } + utf8 = (char*) malloc((size_t) needed); + if (utf8 == NULL) { + return JAVA_NULL; + } + if (WideCharToMultiByte(CP_UTF8, 0, wide, -1, utf8, needed, NULL, NULL) <= 0) { + free(utf8); + return JAVA_NULL; + } + result = newStringFromCString(threadStateData, utf8); + free(utf8); + return result; +} + +} /* namespace */ + +extern "C" { + +extern struct clazz class_array1__java_lang_String; + +/* Runs on the pump thread (dispatched from cn1WinWndProc on WM_CN1_FILEDIALOGEX). */ +LRESULT cn1WinFileDialogExHandleMessage(WPARAM wp) { + Cn1FdxRequest* r = (Cn1FdxRequest*) wp; + HRESULT init; + if (r == NULL) { + return 0; + } + /* The pump thread is normally an STA already (WebView2, OLE drag and drop); + * S_FALSE / RPC_E_CHANGED_MODE just mean COM is up, and only a call that + * succeeded is balanced with CoUninitialize. */ + init = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); + cn1FdxRun(r); + if (SUCCEEDED(init)) { + CoUninitialize(); + } + return 0; +} + +JAVA_OBJECT com_codename1_impl_windows_WindowsNative_fileDialogEx___boolean_java_lang_String_java_lang_String_java_lang_String_java_lang_String_java_lang_String_1ARRAY_int_boolean_boolean_boolean_boolean_java_lang_String_1ARRAY_java_lang_String_java_lang_String_R_java_lang_String_1ARRAY( + CODENAME_ONE_THREAD_STATE, JAVA_BOOLEAN save, JAVA_OBJECT title, JAVA_OBJECT acceptLabel, + JAVA_OBJECT initialDir, JAVA_OBJECT suggestedName, JAVA_OBJECT filters, JAVA_INT selectedFilter, + JAVA_BOOLEAN multi, JAVA_BOOLEAN dirs, JAVA_BOOLEAN confirmOverwrite, JAVA_BOOLEAN showHidden, + JAVA_OBJECT options, JAVA_OBJECT clientGuid, JAVA_OBJECT defaultFolder) { + Cn1FdxRequest r; + JAVA_OBJECT arr = JAVA_NULL; + int i; + /* Headless screenshot mode has no window to own the dialog. */ + if (cn1Win.hwnd == NULL) { + return JAVA_NULL; + } + memset(&r, 0, sizeof(r)); + r.save = save ? 1 : 0; + r.multi = multi ? 1 : 0; + r.dirs = dirs ? 1 : 0; + r.confirmOverwrite = confirmOverwrite ? 1 : 0; + r.showHidden = showHidden ? 1 : 0; + r.selectedFilter = selectedFilter; + r.chosenFilter = -1; + /* Every Java string is copied before the thread state is yielded. */ + r.title = cn1FdxReadString(threadStateData, title); + r.acceptLabel = cn1FdxReadString(threadStateData, acceptLabel); + r.initialDir = cn1FdxReadString(threadStateData, initialDir); + cn1FdxNormalizePath(r.initialDir); + r.suggestedName = cn1FdxReadString(threadStateData, suggestedName); + r.clientGuid = cn1FdxReadString(threadStateData, clientGuid); + r.defaultFolder = cn1FdxReadString(threadStateData, defaultFolder); + cn1FdxNormalizePath(r.defaultFolder); + r.filters = cn1FdxReadArray(threadStateData, filters, &r.filterCount); + r.options = cn1FdxReadArray(threadStateData, options, &r.optionCount); + + CN1_YIELD_THREAD; + SendMessageW(cn1Win.hwnd, WM_CN1_FILEDIALOGEX, (WPARAM) &r, 0); + CN1_RESUME_THREAD; + + if (r.accepted && r.pathCount > 0) { + char num[16]; + arr = allocArray(threadStateData, 1 + r.optionCount + r.pathCount, + &class_array1__java_lang_String, sizeof(JAVA_OBJECT), 1); + if (arr != JAVA_NULL) { + JAVA_OBJECT s; + _snprintf(num, sizeof(num) - 1, "%d", r.chosenFilter); + num[sizeof(num) - 1] = 0; + s = newStringFromCString(threadStateData, num); + ((JAVA_OBJECT*) CN1_ARRAY_DATA(arr))[0] = s; + CN1_WRITE_BARRIER(arr, s); + for (i = 0; i < r.optionCount; i++) { + s = r.values != NULL ? cn1FdxToJava(threadStateData, r.values[i]) : JAVA_NULL; + ((JAVA_OBJECT*) CN1_ARRAY_DATA(arr))[1 + i] = s; + CN1_WRITE_BARRIER(arr, s); + } + for (i = 0; i < r.pathCount; i++) { + s = cn1FdxToJava(threadStateData, r.paths[i]); + ((JAVA_OBJECT*) CN1_ARRAY_DATA(arr))[1 + r.optionCount + i] = s; + CN1_WRITE_BARRIER(arr, s); + } + } + } + + free(r.title); + free(r.acceptLabel); + free(r.initialDir); + free(r.suggestedName); + free(r.clientGuid); + free(r.defaultFolder); + cn1FdxFreeArray(r.filters, r.filterCount); + if (r.values != NULL) { + for (i = 0; i < r.optionCount; i++) { + if (r.values[i] == NULL) { + continue; + } + if (r.valueIsCoTask != NULL && r.valueIsCoTask[i]) { + CoTaskMemFree(r.values[i]); + } else { + free(r.values[i]); + } + } + free(r.values); + } + free(r.valueIsCoTask); + cn1FdxFreeArray(r.options, r.optionCount); + for (i = 0; i < r.pathCount; i++) { + CoTaskMemFree(r.paths[i]); + } + free(r.paths); + return arr; +} + +} /* extern "C" */ + +#endif /* _WIN32 */ diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_window.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_window.cpp index 9ece259eac0..cd79b91baf8 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_window.cpp +++ b/Ports/WindowsPort/nativeSources/cn1_windows_window.cpp @@ -874,6 +874,9 @@ LRESULT CALLBACK cn1WinWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam /* Modal file open/save dialog, run synchronously on this (pump) thread * while the EDT blocks in SendMessage (cn1_windows_io.c). */ return cn1WinFileDialogHandleMessage(wParam); + case WM_CN1_FILEDIALOGEX: + /* Rich open/save dialog (cn1_windows_filedialog.cpp), same contract. */ + return cn1WinFileDialogExHandleMessage(wParam); case WM_CN1_NOTIFY: /* Show a local-notification balloon on the tray icon, marshaled from * the notification Timer thread (cn1_windows_notify.c). */ diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index 05f051471f9..f55fffc3c5e 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -2626,6 +2626,51 @@ public void openGallery(com.codename1.ui.events.ActionListener response, int typ response.actionPerformed(result); } + /* IFileOpenDialog / IFileSaveDialog for Display.showOpenDialog/showSaveDialog: + * filter list, multi selection, folders, overwrite confirmation, and the + * portable options through IFileDialogCustomize. A Codename One accessory + * component and a preview cannot be hosted in the shell dialog, so neither + * is reported supported. */ + @Override + public void showOpenDialog(com.codename1.io.filechooser.OpenFileRequest request, + com.codename1.util.SuccessCallback callback) { + String[] answer = WindowsNative.fileDialogEx(false, request.getTitle(), request.getAcceptLabel(), + com.codename1.impl.NativeFileDialogCodec.toNativePath(request.getInitialDirectory()), null, + com.codename1.impl.NativeFileDialogCodec.encodeFilters(request), request.getSelectedFilter(), + request.isMultiSelect(), request.isDirectories(), false, + request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.DESKTOP_SHOW_HIDDEN, false), + com.codename1.impl.NativeFileDialogCodec.encodeOptions(request), + request.getStringHint(com.codename1.io.filechooser.FileDialogHints.WIN_CLIENT_GUID), + com.codename1.impl.NativeFileDialogCodec.toNativePath(request.getStringHint(com.codename1.io.filechooser.FileDialogHints.WIN_DEFAULT_FOLDER))); + callback.onSucess(com.codename1.impl.NativeFileDialogCodec.decode(request, answer)); + } + + @Override + public void showSaveDialog(com.codename1.io.filechooser.SaveFileRequest request, + com.codename1.util.SuccessCallback callback) { + String[] answer = WindowsNative.fileDialogEx(true, request.getTitle(), request.getAcceptLabel(), + com.codename1.impl.NativeFileDialogCodec.toNativePath(request.getInitialDirectory()), + request.getSuggestedName(), + com.codename1.impl.NativeFileDialogCodec.encodeFilters(request), request.getSelectedFilter(), + false, false, request.isConfirmOverwrite(), + request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.DESKTOP_SHOW_HIDDEN, false), + com.codename1.impl.NativeFileDialogCodec.encodeOptions(request), + request.getStringHint(com.codename1.io.filechooser.FileDialogHints.WIN_CLIENT_GUID), + com.codename1.impl.NativeFileDialogCodec.toNativePath(request.getStringHint(com.codename1.io.filechooser.FileDialogHints.WIN_DEFAULT_FOLDER))); + com.codename1.io.filechooser.FileDialogResult result = com.codename1.impl.NativeFileDialogCodec.decode(request, answer); + if (result.isCancelled()) { + callback.onSucess(result); + } else { + completeSaveDialog(request, result, callback); + } + } + + @Override + public boolean isFileDialogFeatureSupported(int feature) { + return feature != com.codename1.io.filechooser.FileDialogFeatures.ACCESSORY_COMPONENT + && feature != com.codename1.io.filechooser.FileDialogFeatures.PREVIEW; + } + @Override public void openImageGallery(com.codename1.ui.events.ActionListener response) { openGallery(response, Display.GALLERY_IMAGE); diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java index 1aa2ac03da9..adf8958f6f5 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java @@ -875,6 +875,19 @@ public static native boolean verifyData(String algorithm, byte[] publicKeyX509, */ public static native String fileDialog(boolean save, int type, String title); + /** + * Shows the rich open/save dialog behind Display.showOpenDialog and + * showSaveDialog (IFileOpenDialog / IFileSaveDialog). Filters and options + * are encoded by {@link com.codename1.impl.NativeFileDialogCodec}, which + * also decodes the answer; {@code null} means cancelled or headless. + * {@code clientGuid} and {@code defaultFolder} carry the Windows-only hints. + * Runs on the pump thread; the caller blocks until the user chooses. + */ + public static native String[] fileDialogEx(boolean save, String title, String acceptLabel, + String initialDir, String suggestedName, String[] filters, int selectedFilter, + boolean multi, boolean dirs, boolean confirmOverwrite, boolean showHidden, String[] options, + String clientGuid, String defaultFolder); + /* ----------------------------------------------------------- media */ /** Creates a Media Foundation player from the given bytes (spooled to a temp From 14a59fa8fc92bcee380d78a056b988cf5dcf2325 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:05:12 +0300 Subject: [PATCH 11/31] macOS: native open/save dialogs over NSOpenPanel/NSSavePanel Filters become an accessory popup that switches the allowed types, options render as checkbox/popup/text controls in the same accessory stack, and the MAC_* hints map to the panel properties. Answers use NativeFileDialogCodec; saves are written through completeSaveDialog. No accessory Component or preview on the Mac yet. Co-Authored-By: Claude Opus 5.5 --- Ports/iOSPort/nativeSources/IOSNative.m | 213 ++++++++++++++++++ .../codename1/impl/ios/IOSImplementation.java | 90 +++++++- .../src/com/codename1/impl/ios/IOSNative.java | 1 + 3 files changed, 300 insertions(+), 4 deletions(-) diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 936f3568b2b..5494cf51877 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -9434,6 +9434,219 @@ void com_codename1_impl_ios_IOSNative_showSaveFileDialog___java_lang_String_bool #endif } +// macOS open/save panels for Display.showOpenDialog/showSaveDialog. Filters +// and options arrive in NativeFileDialogCodec's form, records separated by +// the RS control character (0x1e); the answer goes back the same way. +#if TARGET_OS_OSX +@interface CN1MacFileDialogFilterTarget : NSObject +@property (nonatomic, assign) NSSavePanel *panel; +@property (nonatomic, retain) NSArray *filterExtensions; +- (void)filterChanged:(id)sender; +- (void)applyFilter:(NSInteger)index; +@end + +@implementation CN1MacFileDialogFilterTarget +- (void)applyFilter:(NSInteger)index { + if (index < 0 || index >= (NSInteger)self.filterExtensions.count) { + return; + } + NSArray *exts = [self.filterExtensions objectAtIndex:index]; + BOOL any = exts.count == 0; + for (NSString *e in exts) { + if ([e isEqualToString:@"*"]) any = YES; + } + // allowedFileTypes is what the existing chooser uses; it takes plain + // extensions and avoids linking UniformTypeIdentifiers on the Mac slice. + self.panel.allowedFileTypes = any ? nil : exts; +} +- (void)filterChanged:(id)sender { + [self applyFilter:[(NSPopUpButton *)sender indexOfSelectedItem]]; +} +#ifndef CN1_USE_ARC +- (void)dealloc { + [_filterExtensions release]; + [super dealloc]; +} +#endif +@end + +static NSArray *cn1MacSplitRecords(NSString *s) { + if (s == nil || s.length == 0) return [NSArray array]; + return [s componentsSeparatedByString:@"\x1e"]; +} +#endif + +void com_codename1_impl_ios_IOSNative_showMacFileDialog___boolean_java_lang_String_java_lang_String_java_lang_String_java_lang_String_java_lang_String_java_lang_String_int_java_lang_String_boolean_boolean_boolean_boolean_boolean_boolean_boolean_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_BOOLEAN save, JAVA_OBJECT title, JAVA_OBJECT message, JAVA_OBJECT prompt, JAVA_OBJECT initialDirectory, JAVA_OBJECT suggestedName, JAVA_OBJECT filters, JAVA_INT selectedFilter, JAVA_OBJECT options, JAVA_BOOLEAN multiSelect, JAVA_BOOLEAN directories, JAVA_BOOLEAN showsTagField, JAVA_BOOLEAN canCreateDirectories, JAVA_BOOLEAN showsHiddenFiles, JAVA_BOOLEAN canSelectHiddenExtension, JAVA_BOOLEAN treatsPackagesAsDirectories, JAVA_INT requestId) { +#if TARGET_OS_OSX + NSString *nsTitle = title == JAVA_NULL ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG title); + NSString *nsMessage = message == JAVA_NULL ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG message); + NSString *nsPrompt = prompt == JAVA_NULL ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG prompt); + NSString *nsDir = initialDirectory == JAVA_NULL ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG initialDirectory); + NSString *nsName = suggestedName == JAVA_NULL ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG suggestedName); + NSArray *filterRecords = cn1MacSplitRecords(filters == JAVA_NULL ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG filters)); + NSArray *optionRecords = cn1MacSplitRecords(options == JAVA_NULL ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG options)); +#ifndef CN1_USE_ARC + [nsTitle retain]; [nsMessage retain]; [nsPrompt retain]; [nsDir retain]; [nsName retain]; + [filterRecords retain]; [optionRecords retain]; +#endif + dispatch_async(dispatch_get_main_queue(), ^{ + POOL_BEGIN(); + NSSavePanel *panel; + if (save) { + panel = [NSSavePanel savePanel]; + if (nsName.length > 0) panel.nameFieldStringValue = nsName; + panel.showsTagField = showsTagField; + panel.canSelectHiddenExtension = canSelectHiddenExtension; + } else { + NSOpenPanel *open = [NSOpenPanel openPanel]; + open.allowsMultipleSelection = multiSelect; + open.canChooseDirectories = directories; + open.canChooseFiles = !directories; + panel = open; + } + if (nsTitle.length > 0) panel.title = nsTitle; + if (nsMessage.length > 0) panel.message = nsMessage; + if (nsPrompt.length > 0) panel.prompt = nsPrompt; + if (nsDir.length > 0) panel.directoryURL = [NSURL fileURLWithPath:nsDir isDirectory:YES]; + panel.canCreateDirectories = canCreateDirectories; + panel.showsHiddenFiles = showsHiddenFiles; + panel.treatsFilePackagesAsDirectories = treatsPackagesAsDirectories; + + NSStackView *stack = [NSStackView stackViewWithViews:[NSArray array]]; + stack.orientation = NSUserInterfaceLayoutOrientationVertical; + stack.alignment = NSLayoutAttributeLeading; + stack.edgeInsets = NSEdgeInsetsMake(8, 16, 8, 16); + + CN1MacFileDialogFilterTarget *target = [[CN1MacFileDialogFilterTarget alloc] init]; +#ifndef CN1_USE_ARC + [target autorelease]; +#endif + target.panel = panel; + NSPopUpButton *filterPopup = nil; + if (filterRecords.count > 0 && !directories) { + NSMutableArray *exts = [NSMutableArray array]; + filterPopup = [[NSPopUpButton alloc] initWithFrame:NSMakeRect(0, 0, 240, 26) pullsDown:NO]; +#ifndef CN1_USE_ARC + [filterPopup autorelease]; +#endif + for (NSString *rec in filterRecords) { + NSArray *parts = [rec componentsSeparatedByString:@"\n"]; + [filterPopup addItemWithTitle:[parts objectAtIndex:0]]; + NSMutableArray *e = [NSMutableArray array]; + for (NSUInteger i = 1; i < parts.count; i++) { + NSString *x = [parts objectAtIndex:i]; + if ([x hasPrefix:@"."]) x = [x substringFromIndex:1]; + if (x.length > 0) [e addObject:x]; + } + [exts addObject:e]; + } + target.filterExtensions = exts; + NSInteger sel = selectedFilter >= 0 && selectedFilter < (JAVA_INT)filterRecords.count ? selectedFilter : 0; + [filterPopup selectItemAtIndex:sel]; + filterPopup.target = target; + filterPopup.action = @selector(filterChanged:); + [target applyFilter:sel]; + if (filterRecords.count > 1) { + [stack addArrangedSubview:filterPopup]; + } + } + + // One control per option, in order: type 1 checkbox, 2 choice, 3 text. + NSMutableArray *optionControls = [NSMutableArray array]; + for (NSString *rec in optionRecords) { + NSArray *parts = [rec componentsSeparatedByString:@"\n"]; + if (parts.count < 4) { [optionControls addObject:[NSNull null]]; continue; } + int type = [[parts objectAtIndex:0] intValue]; + NSString *label = [parts objectAtIndex:2]; + NSString *def = [parts objectAtIndex:3]; + NSView *control; + if (type == 1) { + NSButton *b = [NSButton checkboxWithTitle:label target:nil action:nil]; + b.state = [def isEqualToString:@"true"] ? NSControlStateValueOn : NSControlStateValueOff; + control = b; + [stack addArrangedSubview:b]; + } else { + NSTextField *l = [NSTextField labelWithString:label]; + NSView *field; + if (type == 2) { + NSPopUpButton *p = [[NSPopUpButton alloc] initWithFrame:NSMakeRect(0, 0, 200, 26) pullsDown:NO]; +#ifndef CN1_USE_ARC + [p autorelease]; +#endif + for (NSUInteger i = 4; i < parts.count; i++) [p addItemWithTitle:[parts objectAtIndex:i]]; + if (def.length > 0 && [p indexOfItemWithTitle:def] >= 0) [p selectItemWithTitle:def]; + field = p; + } else { + NSTextField *t = [NSTextField textFieldWithString:def]; + [t.widthAnchor constraintGreaterThanOrEqualToConstant:200].active = YES; + field = t; + } + control = field; + [stack addArrangedSubview:[NSStackView stackViewWithViews:[NSArray arrayWithObjects:l, field, nil]]]; + } + [optionControls addObject:control]; + } + if (stack.arrangedSubviews.count > 0) { + [stack layoutSubtreeIfNeeded]; + NSSize fit = stack.fittingSize; + stack.frame = NSMakeRect(0, 0, MAX(fit.width, 300), fit.height); + panel.accessoryView = stack; + if ([panel isKindOfClass:[NSOpenPanel class]]) { + ((NSOpenPanel *)panel).accessoryViewDisclosed = YES; + } + } + + NSWindow *owner = [CN1MacKeyRenderingHostView() window]; + void (^completed)(NSModalResponse) = ^(NSModalResponse response) { + POOL_BEGIN(); + NSString *answer = nil; + if (response == NSModalResponseOK) { + NSMutableArray *out = [NSMutableArray array]; + [out addObject:[NSString stringWithFormat:@"%ld", (long)(filterPopup == nil ? -1 : [filterPopup indexOfSelectedItem])]]; + for (id c in optionControls) { + // NSPopUpButton is an NSButton subclass, so it is tested first. + if ([c isKindOfClass:[NSPopUpButton class]]) { + NSString *t = [(NSPopUpButton *)c titleOfSelectedItem]; + [out addObject:t == nil ? @"" : t]; + } else if ([c isKindOfClass:[NSButton class]]) { + [out addObject:((NSButton *)c).state == NSControlStateValueOn ? @"true" : @"false"]; + } else if ([c isKindOfClass:[NSTextField class]]) { + [out addObject:((NSTextField *)c).stringValue]; + } else { + [out addObject:@""]; + } + } + NSArray *urls = [panel isKindOfClass:[NSOpenPanel class]] ? ((NSOpenPanel *)panel).URLs + : (panel.URL == nil ? [NSArray array] : [NSArray arrayWithObject:panel.URL]); + for (NSURL *u in urls) [out addObject:[u path]]; + if (urls.count > 0) answer = [out componentsJoinedByString:@"\x1e"]; + } + // The popup holds its target weakly; this capture keeps it alive + // until the panel closes. + [target self]; + [panel orderOut:nil]; + if (owner != nil && owner.visible) [owner makeKeyAndOrderFront:nil]; + struct ThreadLocalData *threadStateData = getThreadLocalData(); + com_codename1_impl_ios_IOSImplementation_macFileDialogResult___java_lang_String_int( + threadStateData, answer == nil ? JAVA_NULL : fromNSString(threadStateData, answer), requestId); + POOL_END(); + }; + if (owner != nil) { + [panel beginSheetModalForWindow:owner completionHandler:completed]; + } else { + [panel beginWithCompletionHandler:completed]; + } +#ifndef CN1_USE_ARC + [nsTitle release]; [nsMessage release]; [nsPrompt release]; [nsDir release]; [nsName release]; + [filterRecords release]; [optionRecords release]; +#endif + POOL_END(); + }); +#else + com_codename1_impl_ios_IOSImplementation_macFileDialogResult___java_lang_String_int(CN1_THREAD_STATE_PASS_ARG JAVA_NULL, requestId); +#endif +} + #ifdef INCLUDE_PHOTOLIBRARY_USAGE #ifdef ENABLE_GALLERY_MULTISELECT diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index b7cdfbd31ee..2770ace492e 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -6821,7 +6821,10 @@ private static String stripFileScheme(String path) { @Override public boolean isFileDialogFeatureSupported(int feature) { if (isMacPlatform()) { - return super.isFileDialogFeatureSupported(feature); + // No accessory Component or preview: there is no way to host a + // Codename One peer inside the panel's accessory NSView yet. + return feature != com.codename1.io.filechooser.FileDialogFeatures.ACCESSORY_COMPONENT + && feature != com.codename1.io.filechooser.FileDialogFeatures.PREVIEW; } return feature == com.codename1.io.filechooser.FileDialogFeatures.NATIVE || feature == com.codename1.io.filechooser.FileDialogFeatures.MULTI_SELECT @@ -6833,8 +6836,7 @@ public boolean isFileDialogFeatureSupported(int feature) { public void showOpenDialog(com.codename1.io.filechooser.OpenFileRequest request, SuccessCallback callback) { if (isMacPlatform()) { - // The Mac port answers through its NSOpenPanel chooser. - super.showOpenDialog(request, callback); + showMacDialog(request, false, request.isMultiSelect(), request.isDirectories(), callback); return; } java.util.ArrayList uts = new java.util.ArrayList(); @@ -6871,7 +6873,9 @@ public void showOpenDialog(com.codename1.io.filechooser.OpenFileRequest request, public void showSaveDialog(final com.codename1.io.filechooser.SaveFileRequest request, final SuccessCallback callback) { if (isMacPlatform()) { - super.showSaveDialog(request, callback); + // NSSavePanel always confirms an overwrite itself; AppKit has no + // public switch for it, so isConfirmOverwrite(false) is not honoured. + showMacDialog(request, true, false, false, callback); return; } String name = request.getSuggestedName(); @@ -6922,6 +6926,84 @@ public void run() { }); } + /// Record separator between the codec's strings on the Mac native boundary + /// (ASCII RS); the codec's own records use newlines internally. + private static final char MAC_DIALOG_RS = (char) 30; + + private static String joinRecords(String[] values) { + if (values.length == 0) { + return null; + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < values.length; i++) { + if (i > 0) { + sb.append(MAC_DIALOG_RS); + } + sb.append(values[i].replace(MAC_DIALOG_RS, ' ')); + } + return sb.toString(); + } + + private void showMacDialog(com.codename1.io.filechooser.FileDialogRequest request, boolean save, + boolean multi, boolean dirs, + SuccessCallback callback) { + int id = registerFileDialog(new PendingFileDialog(request, callback, null)); + String name = save ? ((com.codename1.io.filechooser.SaveFileRequest) request).getSuggestedName() : null; + nativeInstance.showMacFileDialog(save, request.getTitle(), request.getMessage(), request.getAcceptLabel(), + com.codename1.impl.NativeFileDialogCodec.toNativePath(request.getInitialDirectory()), name, + joinRecords(com.codename1.impl.NativeFileDialogCodec.encodeFilters(request)), + request.getSelectedFilter(), + joinRecords(com.codename1.impl.NativeFileDialogCodec.encodeOptions(request)), + multi, dirs, + request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.MAC_SHOWS_TAG_FIELD, false), + request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.MAC_CAN_CREATE_DIRECTORIES, true), + request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.MAC_SHOWS_HIDDEN_FILES, false), + request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.MAC_CAN_SELECT_HIDDEN_EXTENSION, false), + request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.MAC_TREATS_PACKAGES_AS_DIRECTORIES, false), + id); + } + + /// Native completion of showMacFileDialog: the codec's answer joined with + /// RS, or null when cancelled. Called on the AppKit main thread. + public static void macFileDialogResult(final String answer, final int requestId) { + final PendingFileDialog pending; + synchronized (fileDialogRequests) { + pending = fileDialogRequests.remove(requestId); + } + if (pending == null) { + return; // Duplicate or unknown completion. + } + Runnable delivery = new Runnable() { + @Override + public void run() { + String[] parts = null; + if (answer != null) { + // Split by hand: an empty text option is a real, positional value. + java.util.ArrayList list = new java.util.ArrayList(); + int start = 0; + for (int i = 0; i <= answer.length(); i++) { + if (i == answer.length() || answer.charAt(i) == MAC_DIALOG_RS) { + list.add(answer.substring(start, i)); + start = i + 1; + } + } + parts = list.toArray(new String[list.size()]); + } + com.codename1.io.filechooser.FileDialogResult r = + com.codename1.impl.NativeFileDialogCodec.decode(pending.request, parts); + if (!r.isCancelled() && pending.request instanceof com.codename1.io.filechooser.SaveFileRequest) { + instance.completeSaveDialog( + (com.codename1.io.filechooser.SaveFileRequest) pending.request, r, pending.callback); + return; + } + pending.callback.onSucess(r); + } + }; + if (!deliverPickerResultOnEdt(delivery)) { + delivery.run(); + } + } + /// Native completion of showOpenFileDialog and showSaveFileDialog. `paths` /// is newline separated, null when cancelled. Called on the UIKit main /// thread; the callback is delivered on the EDT. diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index 6b3c9178a10..8acba9ed500 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -701,6 +701,7 @@ native void prepareNativeDrag(String mimeTypes, int allowedActions, byte[] dragI native void openFileChooserWithId(String accept, int requestId); native void showOpenFileDialog(String utTypes, String extensions, boolean multiSelect, boolean directories, String initialDirectory, boolean showExtensions, int requestId); native void showSaveFileDialog(String path, boolean asCopy, String initialDirectory, int requestId); + native void showMacFileDialog(boolean save, String title, String message, String prompt, String initialDirectory, String suggestedName, String filters, int selectedFilter, String options, boolean multiSelect, boolean directories, boolean showsTagField, boolean canCreateDirectories, boolean showsHiddenFiles, boolean canSelectHiddenExtension, boolean treatsPackagesAsDirectories, int requestId); // Low-level camera API (com.codename1.camera). Backed by CN1Camera.m // which wraps AVCaptureSession. The IOSCameraImpl class on the Java side From 71c2a5a6ee1703a6382cc675bde61c57e5c51a6e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:05:29 +0300 Subject: [PATCH 12/31] Docs: accessory and preview are not hosted by the macOS panels Co-Authored-By: Claude Opus 5.5 --- .../src/com/codename1/io/filechooser/FileDialogRequest.java | 4 ++-- docs/developer-guide/io.asciidoc | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CodenameOne/src/com/codename1/io/filechooser/FileDialogRequest.java b/CodenameOne/src/com/codename1/io/filechooser/FileDialogRequest.java index d22e8a24365..bda03d8accf 100644 --- a/CodenameOne/src/com/codename1/io/filechooser/FileDialogRequest.java +++ b/CodenameOne/src/com/codename1/io/filechooser/FileDialogRequest.java @@ -177,8 +177,8 @@ public T addOption(FileDialogOption option) { /// Embeds a Codename One component in the dialog, such as a format and /// quality panel for an export. Only shown where - /// `FileDialogFeatures#ACCESSORY_COMPONENT` is supported (the native macOS - /// port and the Codename One fallback dialog); use `#addOption(FileDialogOption)` for + /// `FileDialogFeatures#ACCESSORY_COMPONENT` is supported (today only the Codename One fallback + /// save form); use `#addOption(FileDialogOption)` for /// controls that must appear on every desktop. The component is still /// reachable from the callback, so its state can be read after the dialog /// closes. diff --git a/docs/developer-guide/io.asciidoc b/docs/developer-guide/io.asciidoc index 37d1d6b31ef..38963c08e0f 100644 --- a/docs/developer-guide/io.asciidoc +++ b/docs/developer-guide/io.asciidoc @@ -156,7 +156,7 @@ Every platform can open and save a single file. Some features apply only where t * **Filters** (`FileTypeFilter`) carry extensions, MIME types and Apple type identifiers, and each platform reads the form it understands. Extensions alone work everywhere. * **Options** (`FileDialogOption`) are declarative checkboxes, choice lists and text fields. Each desktop maps them to its own controls. Where a platform shows none, `FileDialogResult.getOptionValue()` returns the default value. -* **Accessory** components are Codename One components embedded in the dialog. Use them only when options aren't enough, because few platforms can host one. +* **Accessory** components are Codename One components embedded in the dialog. Today only the Codename One fallback save form shows one, so prefer options. * **Hints** (`FileDialogHints`) are platform specific settings, such as the macOS tag field, a Windows client GUID or the folder the browser starts in. A platform that doesn't recognize a hint ignores it. Call `Display.isFileDialogFeatureSupported()` with a `FileDialogFeatures` constant to check a feature at runtime: @@ -184,7 +184,7 @@ include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/g |macOS |`NSOpenPanel` |`NSSavePanel` -|Format list, options, accessory component, preview and tag field +|Format list, options and tag field. No accessory or preview. |Windows |`IFileOpenDialog` From c6b20e322018a4ea4bb360f5c603b24416e45d0f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:30:33 +0300 Subject: [PATCH 13/31] File dialogs: fix CI gates and address review findings - Copyright headers on the new tests; classify the fallback save form in FrameworkChromeNeverWindowsTest; Vale quote punctuation in the guide. - Linux: add the filter's extension in the GTK native and confirm the overwrite of that name, since GTK only checked the typed basename. - Core: saving a sourceFile onto itself no longer truncates it. - Android: refuse to launch while another activity result is outstanding; stop reporting INITIAL_DIRECTORY, which only the URI hint honours. - iOS: convert extension filters to legacy UTIs before iOS 14; copy picked documents off the main thread. - JavaScript: report a dismissed picker as cancelled; a save too large for the heap still answers the callback. - Linux/Windows: deliver results on the EDT. Co-Authored-By: Claude Opus 5.5 --- .../com/codename1/impl/FileDialogSupport.java | 42 ++++++- .../codename1/impl/NativeFileDialogCodec.java | 19 ++++ .../impl/android/AndroidFileDialogs.java | 18 ++- .../impl/html5/HTML5Implementation.java | 12 +- .../teavm/ext/usermedia/FileChooser.java | 107 +++++++++++------- .../nativeSources/cn1_linux_services.c | 64 ++++++++++- .../impl/linux/LinuxImplementation.java | 4 +- .../impl/windows/WindowsImplementation.java | 4 +- Ports/iOSPort/nativeSources/IOSNative.m | 37 +++++- docs/developer-guide/io.asciidoc | 2 +- .../codename1/impl/FileDialogSupportTest.java | 32 ++++++ .../io/filechooser/FileDialogRequestTest.java | 22 ++++ .../ui/FrameworkChromeNeverWindowsTest.java | 1 + 13 files changed, 299 insertions(+), 65 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/FileDialogSupport.java b/CodenameOne/src/com/codename1/impl/FileDialogSupport.java index f21dc7dc8e4..f1120e42dec 100644 --- a/CodenameOne/src/com/codename1/impl/FileDialogSupport.java +++ b/CodenameOne/src/com/codename1/impl/FileDialogSupport.java @@ -195,6 +195,12 @@ static void completeSave(final SaveFileRequest request, final FileDialogResult r @Override public void run() { try { + if (isSameFile(request.getSourceFile(), result.getPath())) { + // Opening the destination would truncate the source before it + // is read, so the file already holds the content: nothing to do. + deliver(result, callback); + return; + } OutputStream out = FileSystemStorage.getInstance().openOutputStream(result.getPath()); try { request.writeContent(result, out); @@ -205,13 +211,37 @@ public void run() { Log.e(err); result.setError(err); } - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - callback.onSucess(result); - } - }); + deliver(result, callback); + } + }); + } + + private static void deliver(final FileDialogResult result, final SuccessCallback callback) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + callback.onSucess(result); } }); } + + /// True when the save's source file and destination name the same path, + /// ignoring a `file://` prefix on either. + static boolean isSameFile(String source, String destination) { + if (source == null || destination == null) { + return false; + } + return stripFileScheme(source).equals(stripFileScheme(destination)); + } + + private static String stripFileScheme(String path) { + String p = path; + if (p.startsWith("file://")) { + p = p.substring(7); + } + while (p.startsWith("//")) { + p = p.substring(1); + } + return p; + } } diff --git a/CodenameOne/src/com/codename1/impl/NativeFileDialogCodec.java b/CodenameOne/src/com/codename1/impl/NativeFileDialogCodec.java index 2434d30dacd..dc6eded6c81 100644 --- a/CodenameOne/src/com/codename1/impl/NativeFileDialogCodec.java +++ b/CodenameOne/src/com/codename1/impl/NativeFileDialogCodec.java @@ -27,6 +27,8 @@ import com.codename1.io.filechooser.FileDialogRequest; import com.codename1.io.filechooser.FileDialogResult; import com.codename1.io.filechooser.FileTypeFilter; +import com.codename1.ui.Display; +import com.codename1.util.SuccessCallback; import java.util.HashMap; import java.util.Map; @@ -151,6 +153,23 @@ public static FileDialogResult decode(FileDialogRequest request, String[] ans return new FileDialogResult(request, paths, filter, values); } + /// Delivers a result through `Display.callSerially()`, so the callback runs + /// on the EDT even when the dialog was requested from another thread. + /// + /// #### Parameters + /// + /// - `callback`: the dialog's callback + /// + /// - `result`: the result to deliver + public static void deliver(final SuccessCallback callback, final FileDialogResult result) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + callback.onSucess(result); + } + }); + } + private static String clean(String s) { return s == null ? "" : s.replace('\n', ' '); } diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java b/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java index 78fc108f8db..f1830f82011 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java @@ -71,8 +71,11 @@ static boolean isFeatureSupported(int feature) { case FileDialogFeatures.DIRECTORIES: return Build.VERSION.SDK_INT >= 21; case FileDialogFeatures.INITIAL_DIRECTORY: - // Only through the FileDialogHints.ANDROID_INITIAL_URI hint; SAF has no file paths - return Build.VERSION.SDK_INT >= 26; + // The portable initialDirectory(path) is ignored: SAF starts from a + // document URI and a file path has no general mapping to one. The + // FileDialogHints.ANDROID_INITIAL_URI hint (API 26+) is the Android + // equivalent, and a hint is not what this feature reports. + return false; default: return false; } @@ -243,6 +246,17 @@ private static void start(FileDialogRequest request, SuccessCallbackmain bridge (see downloadBytesAsFile), so there is no + // stream to write into. A document too large for the heap must + // still answer the callback, hence the OutOfMemoryError catch. java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); try { request.writeContent(result, out); - if (!downloadBytesAsFile(fileName, out.toByteArray())) { + byte[] data = out.toByteArray(); + out = null; + if (!downloadBytesAsFile(fileName, data)) { result.setError(new IOException("The browser refused the download")); } } catch (IOException err) { Log.e(err); result.setError(err); + } catch (OutOfMemoryError err) { + out = null; + result.setError(new IOException("The document is too large to download from the browser")); } Display.getInstance().callSerially(new Runnable() { public void run() { diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/teavm/ext/usermedia/FileChooser.java b/Ports/JavaScriptPort/src/main/java/com/codename1/teavm/ext/usermedia/FileChooser.java index 316534f56fc..bc13d575bef 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/teavm/ext/usermedia/FileChooser.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/teavm/ext/usermedia/FileChooser.java @@ -134,60 +134,79 @@ private HTMLInputElement createFileInput() { if (capture != null) { fileEl.setAttribute("capture", capture); } + // A dismissed picker fires "cancel" rather than "change" (Chrome 113+, + // Firefox 91+, Safari 16.4+). It runs the same path with no files, so + // the response hears about a cancellation instead of never hearing at all. + fileEl.addEventListener("cancel", new EventListener() { + @Override + public void handleEvent(Event evt) { + selectionFinished(); + } + }); fileEl.addEventListener("change", new EventListener() { @Override public void handleEvent(Event evt) { - // Run synchronously on the current (green) thread via run() rather - // than start(): a java.lang.Thread never executes on the - // single-threaded HTML5 worker, so the response listener (and the - // invokeAndBlock'd Capture.capturePhoto() that waits on it) would - // otherwise hang forever. The body only does cooperative, - // host-bridge work, so it's safe inline. - // - // The selected .files live on the MAIN thread; on the - // worker port fileEl is a host-ref proxy with no real .files, so - // we read the chosen file bytes through the host bridge - // (nativeSelectedFileCount / nativeSelectedFile) and write them to - // FileSystemStorage as ordinary files the app can open normally. - new Thread() { - public void run() { - if (clickBtn != null) { - clickBtn.getParentNode().removeChild(clickBtn); - clickBtn = null; - } - int count = nativeSelectedFileCount(fileEl); - final String[] paths = readSelectedFiles(count); - if (fileEl.getParentNode() != null) { - fileEl.getParentNode().removeChild(fileEl); - } - if (count <= 0 || paths == null) { - Display.getInstance().callSerially(new Runnable() { - public void run() { - response.actionPerformed(new ActionEvent(null)); - } - }); - } else if (multiple) { - Display.getInstance().callSerially(new Runnable() { - public void run() { - response.actionPerformed(new ActionEvent(paths)); - } - }); - } else { - Display.getInstance().callSerially(new Runnable() { - public void run() { - response.actionPerformed(new ActionEvent(paths[0])); - } - }); - } - } - }.run(); + selectionFinished(); } }); return fileEl; } + + private boolean answered; + + private void selectionFinished() { + if (answered) { + return; + } + answered = true; + // Run synchronously on the current (green) thread via run() rather + // than start(): a java.lang.Thread never executes on the + // single-threaded HTML5 worker, so the response listener (and the + // invokeAndBlock'd Capture.capturePhoto() that waits on it) would + // otherwise hang forever. The body only does cooperative, + // host-bridge work, so it's safe inline. + // + // The selected .files live on the MAIN thread; on the + // worker port fileEl is a host-ref proxy with no real .files, so + // we read the chosen file bytes through the host bridge + // (nativeSelectedFileCount / nativeSelectedFile) and write them to + // FileSystemStorage as ordinary files the app can open normally. + new Thread() { + public void run() { + if (clickBtn != null) { + clickBtn.getParentNode().removeChild(clickBtn); + clickBtn = null; + } + int count = nativeSelectedFileCount(fileEl); + final String[] paths = readSelectedFiles(count); + if (fileEl.getParentNode() != null) { + fileEl.getParentNode().removeChild(fileEl); + } + if (count <= 0 || paths == null) { + Display.getInstance().callSerially(new Runnable() { + public void run() { + response.actionPerformed(new ActionEvent(null)); + } + }); + } else if (multiple) { + Display.getInstance().callSerially(new Runnable() { + public void run() { + response.actionPerformed(new ActionEvent(paths)); + } + }); + } else { + Display.getInstance().callSerially(new Runnable() { + public void run() { + response.actionPerformed(new ActionEvent(paths[0])); + } + }); + } + } + }.run(); + } HTMLButtonElement clickBtn; diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_services.c b/Ports/LinuxPort/nativeSources/cn1_linux_services.c index c94c1052103..04bd5ca5405 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_services.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_services.c @@ -766,6 +766,49 @@ static int cn1FdxSplit(char* spec, char** fields, int max) { return n; } +/* Appends the filter's first extension to a save path that carries none of + * its extensions, the same rule as FileDialogSupport.withExtension, so the + * overwrite check sees the file that will really be written. The filter + * string was already split by cn1FdxSplit into n NUL separated fields: the + * label, then the extensions. Returns 1 when the path was changed. */ +static int cn1FdxAddExtension(GSList* paths, const char* filter, int n) { + const char* path = (const char*) paths->data; + const char* field = filter + strlen(filter) + 1; + const char* first = 0; + size_t len = strlen(path); + int j; + for (j = 1; j < n; j++) { + size_t el = strlen(field); + if (!first) { + first = field; + } + if (len > el + 1 && path[len - el - 1] == '.' + && g_ascii_strcasecmp(path + len - el, field) == 0) { + return 0; + } + field += el + 1; + } + if (!first || !*first) { + return 0; + } + paths->data = g_strconcat(path, ".", first, NULL); + g_free((gpointer) path); + return 1; +} + +static int cn1FdxConfirmReplace(GtkWidget* parent, const char* path) { + GtkWidget* q; + int answer; + gchar* base = g_path_get_basename(path); + q = gtk_message_dialog_new(GTK_WINDOW(parent), GTK_DIALOG_MODAL, + GTK_MESSAGE_QUESTION, GTK_BUTTONS_YES_NO, + "A file named \"%s\" already exists. Do you want to replace it?", base); + answer = gtk_dialog_run(GTK_DIALOG(q)); + gtk_widget_destroy(q); + g_free(base); + return answer == GTK_RESPONSE_YES; +} + static void cn1FileDialogExOnMain(void* p) { CN1FileDlgEx* d = (CN1FileDlgEx*) p; GtkFileChooserAction action; @@ -773,6 +816,7 @@ static void cn1FileDialogExOnMain(void* p) { GtkWidget* extra = 0; GtkWidget* entries[CN1_FDX_MAX_ITEMS]; GtkFileFilter* filters[CN1_FDX_MAX_ITEMS]; + int filterFields[CN1_FDX_MAX_ITEMS]; int i; if (d->save) { action = GTK_FILE_CHOOSER_ACTION_SAVE; @@ -797,6 +841,7 @@ static void cn1FileDialogExOnMain(void* p) { char* fields[CN1_FDX_MAX_ITEMS]; int n = cn1FdxSplit(d->filters[i], fields, CN1_FDX_MAX_ITEMS); int j; + filterFields[i] = n; filters[i] = gtk_file_filter_new(); gtk_file_filter_set_name(filters[i], n > 0 ? fields[0] : ""); for (j = 1; j < n; j++) { @@ -864,7 +909,7 @@ static void cn1FileDialogExOnMain(void* p) { gtk_widget_show_all(extra); gtk_file_chooser_set_extra_widget(GTK_FILE_CHOOSER(dialog), extra); } - if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) { + while (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) { GtkFileFilter* chosen = gtk_file_chooser_get_filter(GTK_FILE_CHOOSER(dialog)); d->accepted = 1; d->chosenFilter = -1; @@ -885,6 +930,23 @@ static void cn1FileDialogExOnMain(void* p) { d->values[i] = v ? strdup(v) : 0; } d->paths = gtk_file_chooser_get_filenames(GTK_FILE_CHOOSER(dialog)); + if (d->save && d->paths && d->chosenFilter >= 0 + && cn1FdxAddExtension(d->paths, d->filters[d->chosenFilter], filterFields[d->chosenFilter]) + && d->confirmOverwrite + && g_file_test((const char*) d->paths->data, G_FILE_TEST_EXISTS) + && !cn1FdxConfirmReplace(dialog, (const char*) d->paths->data)) { + /* GTK confirmed the typed name, not the one with the extension + * added; the user declined replacing that one, so ask again */ + for (i = 0; i < d->optionCount; i++) { + free(d->values[i]); + d->values[i] = 0; + } + g_slist_free_full(d->paths, g_free); + d->paths = 0; + d->accepted = 0; + continue; + } + break; } gtk_widget_destroy(dialog); } diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index 0e8a4d3de7a..e3d0702b791 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -2620,7 +2620,7 @@ public void showOpenDialog(com.codename1.io.filechooser.OpenFileRequest request, request.isMultiSelect(), request.isDirectories(), false, request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.DESKTOP_SHOW_HIDDEN, false), com.codename1.impl.NativeFileDialogCodec.encodeOptions(request)); - callback.onSucess(com.codename1.impl.NativeFileDialogCodec.decode(request, answer)); + com.codename1.impl.NativeFileDialogCodec.deliver(callback, com.codename1.impl.NativeFileDialogCodec.decode(request, answer)); } @Override @@ -2636,7 +2636,7 @@ public void showSaveDialog(com.codename1.io.filechooser.SaveFileRequest request, com.codename1.io.filechooser.FileDialogResult result = com.codename1.impl.NativeFileDialogCodec.decode(request, answer); if (result.isCancelled()) { - callback.onSucess(result); + com.codename1.impl.NativeFileDialogCodec.deliver(callback, result); } else { completeSaveDialog(request, result, callback); } diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index f55fffc3c5e..a474db4ecc1 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -2642,7 +2642,7 @@ public void showOpenDialog(com.codename1.io.filechooser.OpenFileRequest request, com.codename1.impl.NativeFileDialogCodec.encodeOptions(request), request.getStringHint(com.codename1.io.filechooser.FileDialogHints.WIN_CLIENT_GUID), com.codename1.impl.NativeFileDialogCodec.toNativePath(request.getStringHint(com.codename1.io.filechooser.FileDialogHints.WIN_DEFAULT_FOLDER))); - callback.onSucess(com.codename1.impl.NativeFileDialogCodec.decode(request, answer)); + com.codename1.impl.NativeFileDialogCodec.deliver(callback, com.codename1.impl.NativeFileDialogCodec.decode(request, answer)); } @Override @@ -2659,7 +2659,7 @@ public void showSaveDialog(com.codename1.io.filechooser.SaveFileRequest request, com.codename1.impl.NativeFileDialogCodec.toNativePath(request.getStringHint(com.codename1.io.filechooser.FileDialogHints.WIN_DEFAULT_FOLDER))); com.codename1.io.filechooser.FileDialogResult result = com.codename1.impl.NativeFileDialogCodec.decode(request, answer); if (result.isCancelled()) { - callback.onSucess(result); + com.codename1.impl.NativeFileDialogCodec.deliver(callback, result); } else { completeSaveDialog(request, result, callback); } diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 5494cf51877..00f04fc940d 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -9268,15 +9268,34 @@ - (void)finish:(UIDocumentPickerViewController *)controller paths:(NSString *)pa } - (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray *)urls { - NSMutableArray *paths = [NSMutableArray array]; - for (NSURL *url in urls) { + if (self.exporting) { // An export reports where the file went; it is not copied back. - NSString *p = self.exporting ? [url path] : cn1FileDialogCopyToTemp(url); - if (p != nil) { - [paths addObject:p]; + NSMutableArray *paths = [NSMutableArray array]; + for (NSURL *url in urls) { + if ([url path] != nil) { + [paths addObject:[url path]]; + } } + [self finish:controller paths:[paths count] == 0 ? nil : [paths componentsJoinedByString:@"\n"]]; + return; } - [self finish:controller paths:[paths count] == 0 ? nil : [paths componentsJoinedByString:@"\n"]]; + // Delegate callbacks run on the main thread, and an import may copy a + // whole folder or wait for a cloud download; copying here would freeze + // the UI. The copy takes and releases security-scoped access itself, and + // the blocks keep the controller and the URLs alive until it is done. + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSMutableArray *paths = [NSMutableArray array]; + for (NSURL *url in urls) { + NSString *p = cn1FileDialogCopyToTemp(url); + if (p != nil) { + [paths addObject:p]; + } + } + NSString *joined = [paths count] == 0 ? nil : [paths componentsJoinedByString:@"\n"]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self finish:controller paths:joined]; + }); + }); } - (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentAtURL:(NSURL *)url { @@ -9369,6 +9388,12 @@ void com_codename1_impl_ios_IOSNative_showOpenFileDialog___java_lang_String_java [legacy addObject:(NSString *)kUTTypeFolder]; } else { [legacy addObjectsFromArray:utList]; + // Before iOS 14 there is no UTType class, so an extension-only + // filter is converted to its legacy UTI here; without that the + // picker would fall back to every document. + for (NSString *ext in extList) { + cn1AddFileChooserDocumentType(legacy, kUTTagClassFilenameExtension, ext); + } if ([legacy count] == 0) { [legacy addObject:(NSString *)kUTTypeItem]; } diff --git a/docs/developer-guide/io.asciidoc b/docs/developer-guide/io.asciidoc index 38963c08e0f..73feb830f21 100644 --- a/docs/developer-guide/io.asciidoc +++ b/docs/developer-guide/io.asciidoc @@ -138,7 +138,7 @@ No photo-library or media permissions are added by this API. On Android the syst ==== Open and save dialogs -`openFileChooser()` picks one file. Desktop applications usually need more: a *Save* dialog with a suggested name and a choice of formats, several named filters, extra controls such as "Include charts", and a live preview while the user browses. `Display.showOpenDialog()` and `Display.showSaveDialog()` cover these cases. Each takes a request object from `com.codename1.io.filechooser` and a callback that runs on the EDT, including when the user cancels. +`openFileChooser()` picks one file. Desktop applications usually need more: a *Save* dialog with a suggested name and a choice of formats, several named filters, extra controls such as an *Include charts* checkbox, and a live preview while the user browses. `Display.showOpenDialog()` and `Display.showSaveDialog()` cover these cases. Each takes a request object from `com.codename1.io.filechooser` and a callback that runs on the EDT, including when the user cancels. [source,java] ---- diff --git a/maven/core-unittests/src/test/java/com/codename1/impl/FileDialogSupportTest.java b/maven/core-unittests/src/test/java/com/codename1/impl/FileDialogSupportTest.java index 42f6aa37d66..87f09fe652d 100644 --- a/maven/core-unittests/src/test/java/com/codename1/impl/FileDialogSupportTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/impl/FileDialogSupportTest.java @@ -1,12 +1,44 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codename1.impl; import com.codename1.io.filechooser.FileTypeFilter; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; class FileDialogSupportTest { + @Test + void savingOntoTheSourceFileIsRecognized() { + assertTrue(FileDialogSupport.isSameFile("file:///home/a/r.txt", "/home/a/r.txt")); + assertTrue(FileDialogSupport.isSameFile("/home/a/r.txt", "file:///home/a/r.txt")); + assertFalse(FileDialogSupport.isSameFile("/home/a/r.txt", "/home/a/s.txt")); + assertFalse(FileDialogSupport.isSameFile(null, "/home/a/r.txt")); + } + @Test void withExtensionAppendsTheFiltersFirstExtensionOnlyWhenMissing() { FileTypeFilter pdf = new FileTypeFilter("PDF").extensions("pdf", "PDFA"); diff --git a/maven/core-unittests/src/test/java/com/codename1/io/filechooser/FileDialogRequestTest.java b/maven/core-unittests/src/test/java/com/codename1/io/filechooser/FileDialogRequestTest.java index 6d2ef97caaf..1d47d46127b 100644 --- a/maven/core-unittests/src/test/java/com/codename1/io/filechooser/FileDialogRequestTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/io/filechooser/FileDialogRequestTest.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codename1.io.filechooser; import org.junit.jupiter.api.Test; diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/FrameworkChromeNeverWindowsTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/FrameworkChromeNeverWindowsTest.java index b7e3f000e29..f002cae6be3 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/FrameworkChromeNeverWindowsTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/FrameworkChromeNeverWindowsTest.java @@ -195,6 +195,7 @@ public void newFrameworkPopupsHaveToBeClassified() throws IOException { // right: a file chooser, a crash report, a signature pad, a share sheet and // a country list are dialogs, not chrome placed against a control. "com/codename1/impl/CodenameOneImplementation.java", + "com/codename1/impl/FileDialogSupport.java", "com/codename1/system/DefaultCrashReporter.java", "com/codename1/components/SignatureComponent.java", "com/codename1/components/ShareButton.java", From 78793370584c595b4af3522e053456cc4f9a289c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:07:32 +0300 Subject: [PATCH 14/31] File dialogs: address the second review round - Core: a content writer still runs when the destination is the source file; the default implementation no longer advertises INITIAL_DIRECTORY. - JavaSE: filtered saves (and filtered opens on Windows, where AWT's FilenameFilter does nothing) use the Swing chooser, which confirms the overwrite of the name with the extension added. - Android: picks are checked against extension filters the MIME query could not express; a picked folder is copied to the cache so the result is a path FileSystemStorage can use. - macOS: filters given as UTIs reach allowedFileTypes. - iOS: close the staged export stream in a finally (SpotBugs DLS). Co-Authored-By: Claude Opus 5.5 --- .../impl/CodenameOneImplementation.java | 5 +- .../com/codename1/impl/FileDialogSupport.java | 9 +- .../codename1/impl/NativeFileDialogCodec.java | 24 +++ .../impl/android/AndroidFileDialogs.java | 137 +++++++++++++++--- .../impl/javase/JavaSEFileDialogs.java | 14 ++ Ports/iOSPort/nativeSources/IOSNative.m | 3 +- .../codename1/impl/ios/IOSImplementation.java | 16 +- 7 files changed, 176 insertions(+), 32 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index a100a32c5a0..275e622692a 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -8941,8 +8941,9 @@ protected void completeSaveDialog(SaveFileRequest request, FileDialogResult resu /// /// true if supported public boolean isFileDialogFeatureSupported(int feature) { - return feature == FileDialogFeatures.INITIAL_DIRECTORY - || feature == FileDialogFeatures.SAVE_NAME_EDITING; + // The default open dialog goes through openFileChooser(), which takes no + // starting folder, so INITIAL_DIRECTORY is left to ports that honour it. + return feature == FileDialogFeatures.SAVE_NAME_EDITING; } /// Opens the device image gallery diff --git a/CodenameOne/src/com/codename1/impl/FileDialogSupport.java b/CodenameOne/src/com/codename1/impl/FileDialogSupport.java index f1120e42dec..b282663c1e7 100644 --- a/CodenameOne/src/com/codename1/impl/FileDialogSupport.java +++ b/CodenameOne/src/com/codename1/impl/FileDialogSupport.java @@ -195,9 +195,12 @@ static void completeSave(final SaveFileRequest request, final FileDialogResult r @Override public void run() { try { - if (isSameFile(request.getSourceFile(), result.getPath())) { - // Opening the destination would truncate the source before it - // is read, so the file already holds the content: nothing to do. + if (request.getContent() == null + && isSameFile(request.getSourceFile(), result.getPath())) { + // A plain copy onto its own source: opening the destination + // would truncate the source before it is read, and the file + // already holds the content. A content writer takes + // precedence over the source, so it still runs below. deliver(result, callback); return; } diff --git a/CodenameOne/src/com/codename1/impl/NativeFileDialogCodec.java b/CodenameOne/src/com/codename1/impl/NativeFileDialogCodec.java index dc6eded6c81..f207717127e 100644 --- a/CodenameOne/src/com/codename1/impl/NativeFileDialogCodec.java +++ b/CodenameOne/src/com/codename1/impl/NativeFileDialogCodec.java @@ -55,6 +55,25 @@ private NativeFileDialogCodec() { /// /// one string per filter public static String[] encodeFilters(FileDialogRequest request) { + return encodeFilters(request, false); + } + + /// Encodes the request's filters, optionally followed by each filter's + /// uniform type identifiers. Only the macOS panels take those: their + /// `allowedFileTypes` accepts extensions and UTIs alike, so a filter given + /// only as `public.png` still restricts the panel. GTK and the Windows + /// shell would read a UTI as an extension. + /// + /// #### Parameters + /// + /// - `request`: the request + /// + /// - `includeUtTypes`: true to append the filters' UTIs after the extensions + /// + /// #### Returns + /// + /// one string per filter + public static String[] encodeFilters(FileDialogRequest request, boolean includeUtTypes) { FileTypeFilter[] filters = request.getFilters(); String[] out = new String[filters.length]; for (int i = 0; i < filters.length; i++) { @@ -62,6 +81,11 @@ public static String[] encodeFilters(FileDialogRequest request) { for (String e : filters[i].getExtensions()) { sb.append('\n').append(clean(e)); } + if (includeUtTypes) { + for (String u : filters[i].getUtTypes()) { + sb.append('\n').append(clean(u)); + } + } out[i] = sb.toString(); } return out; diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java b/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java index f1830f82011..386a0752dc7 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java @@ -139,24 +139,27 @@ public void onActivityResult(int requestCode, int resultCode, Intent data) { } } } - if (request.isDirectories()) { - String[] paths = new String[uris.size()]; - for (int i = 0; i < paths.length; i++) { - paths[i] = uris.get(i).toString(); - } - deliver(callback, new FileDialogResult(request, paths, -1, null)); - return; - } // Copying can be slow for cloud documents, keep it off the UI thread new Thread(new Runnable() { public void run() { FileDialogResult result; try { - String[] paths = new String[uris.size()]; - for (int i = 0; i < paths.length; i++) { - paths[i] = copyToCache(resolver, uris.get(i)); + List paths = new ArrayList(); + for (Uri u : uris) { + if (request.isDirectories()) { + // A tree URI is not a path FileSystemStorage can + // list; copy the folder, as iOS does. + paths.add(copyTreeToCache(resolver, u)); + } else if (acceptedByFilters(request.getFilters(), resolver, u)) { + paths.add(copyToCache(resolver, u)); + } + } + if (paths.isEmpty()) { + result = FileDialogResult.cancelled(request); + result.setError(new IOException("The selected file does not match the dialog's filters")); + } else { + result = new FileDialogResult(request, paths.toArray(new String[paths.size()]), -1, null); } - result = new FileDialogResult(request, paths, -1, null); } catch (IOException e) { result = new FileDialogResult(request, null, -1, null); result.setError(e); @@ -365,19 +368,118 @@ private static String displayName(ContentResolver resolver, Uri uri) { return null; } - private static String copyToCache(ContentResolver resolver, Uri uri) throws IOException { + /* + * The picker filters by MIME type only. An extension the platform cannot map + * widens the request to every type (see mimeTypes), so the selection is + * checked here: a file passes when some filter accepts its name or its type, + * or a filter accepts everything. + */ + private static boolean acceptedByFilters(FileTypeFilter[] filters, ContentResolver resolver, Uri uri) { + if (filters.length == 0) { + return true; + } String name = displayName(resolver, uri); + String type = null; + try { + type = resolver.getType(uri); + } catch (RuntimeException ignored) { + } + for (FileTypeFilter f : filters) { + String[] ext = f.getExtensions(); + String[] mimes = f.getMimeTypes(); + if (ext.length == 0 && mimes.length == 0) { + return true; + } + for (String e : ext) { + if ("*".equals(e)) { + return true; + } + } + if (ext.length > 0 && f.acceptsName(name)) { + return true; + } + if (type != null && mimeMatches(mimes, type)) { + return true; + } + } + return false; + } + + private static boolean mimeMatches(String[] mimes, String type) { + for (String m : mimes) { + if (m.equalsIgnoreCase(type) || "*/*".equals(m)) { + return true; + } + if (m.endsWith("/*") && type.regionMatches(true, 0, m, 0, m.length() - 1)) { + return true; + } + } + return false; + } + + private static String safeName(String name, String fallback) { if (name == null || name.length() == 0) { - name = "document"; + return fallback; } - name = name.replace('/', '_').replace('\\', '_'); - // A directory per pick keeps the display name intact without clobbering an earlier copy + return name.replace('/', '_').replace('\\', '_'); + } + + /** A directory per pick keeps the display name intact without clobbering an earlier copy. */ + private static File pickDirectory(Uri uri) throws IOException { File dir = new File(AndroidNativeUtil.getContext().getCacheDir(), "filedialog" + File.separator + System.currentTimeMillis() + "_" + Integer.toHexString(uri.hashCode())); if (!dir.isDirectory() && !dir.mkdirs()) { throw new IOException("Cannot create " + dir); } - File dest = new File(dir, name); + return dir; + } + + private static String copyTreeToCache(ContentResolver resolver, Uri tree) throws IOException { + String rootId = android.provider.DocumentsContract.getTreeDocumentId(tree); + Uri rootDoc = android.provider.DocumentsContract.buildDocumentUriUsingTree(tree, rootId); + File dest = new File(pickDirectory(tree), safeName(displayName(resolver, rootDoc), "folder")); + copyTreeChildren(resolver, tree, rootId, dest); + return "file://" + dest.getAbsolutePath(); + } + + private static void copyTreeChildren(ContentResolver resolver, Uri tree, String parentId, File dest) + throws IOException { + if (!dest.isDirectory() && !dest.mkdirs()) { + throw new IOException("Cannot create " + dest); + } + Uri children = android.provider.DocumentsContract.buildChildDocumentsUriUsingTree(tree, parentId); + List entries = new ArrayList(); + Cursor cursor = resolver.query(children, new String[]{ + android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID, + android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME, + android.provider.DocumentsContract.Document.COLUMN_MIME_TYPE}, null, null, null); + if (cursor == null) { + throw new IOException("Cannot list " + tree); + } + try { + while (cursor.moveToNext()) { + entries.add(new String[]{cursor.getString(0), cursor.getString(1), cursor.getString(2)}); + } + } finally { + cursor.close(); + } + for (String[] e : entries) { + File child = new File(dest, safeName(e[1], "document")); + if (android.provider.DocumentsContract.Document.MIME_TYPE_DIR.equals(e[2])) { + copyTreeChildren(resolver, tree, e[0], child); + } else { + copyStream(resolver, android.provider.DocumentsContract.buildDocumentUriUsingTree(tree, e[0]), child); + } + } + } + + private static String copyToCache(ContentResolver resolver, Uri uri) throws IOException { + File dest = new File(pickDirectory(uri), safeName(displayName(resolver, uri), "document")); + copyStream(resolver, uri, dest); + return "file://" + dest.getAbsolutePath(); + } + + private static void copyStream(ContentResolver resolver, Uri uri, File dest) throws IOException { InputStream in = resolver.openInputStream(uri); if (in == null) { throw new IOException("Cannot open " + uri); @@ -396,6 +498,5 @@ private static String copyToCache(ContentResolver resolver, Uri uri) throws IOEx } finally { in.close(); } - return "file://" + dest.getAbsolutePath(); } } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEFileDialogs.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEFileDialogs.java index f55aa3e814f..f636e018c2b 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEFileDialogs.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEFileDialogs.java @@ -113,6 +113,15 @@ private static boolean needsSwing(FileDialogRequest request) { || request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.DESKTOP_SHOW_HIDDEN, false)) { return true; } + if (request.getFilters().length > 0) { + // A filtered save adds the filter's extension after the panel has + // closed, so the panel's overwrite check saw a different name; the + // Swing path confirms the final name itself. And AWT's + // FilenameFilter is documented not to work on Windows at all. + if (request instanceof SaveFileRequest || isWindows()) { + return true; + } + } if (request instanceof OpenFileRequest) { OpenFileRequest o = (OpenFileRequest) request; return o.getPreview() != null || o.isDirectories(); @@ -120,6 +129,11 @@ private static boolean needsSwing(FileDialogRequest request) { return false; } + private static boolean isWindows() { + String os = System.getProperty("os.name"); + return os != null && os.regionMatches(true, 0, "windows", 0, 7); + } + private static Frame parentFrame() { Frame[] frames = Frame.getFrames(); for (int iter = 0; iter < frames.length; iter++) { diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 00f04fc940d..84bbb87e71f 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -9481,7 +9481,8 @@ - (void)applyFilter:(NSInteger)index { if ([e isEqualToString:@"*"]) any = YES; } // allowedFileTypes is what the existing chooser uses; it takes plain - // extensions and avoids linking UniformTypeIdentifiers on the Mac slice. + // extensions and UTIs alike (NativeFileDialogCodec appends the filter's + // UTIs for the Mac) and avoids linking UniformTypeIdentifiers on the Mac slice. self.panel.allowedFileTypes = any ? nil : exts; } - (void)filterChanged:(id)sender { diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 2770ace492e..fafd2ee63db 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -6896,16 +6896,16 @@ public void showSaveDialog(final com.codename1.io.filechooser.SaveFileRequest re @Override public void run() { com.codename1.io.FileSystemStorage fs = com.codename1.io.FileSystemStorage.getInstance(); - java.io.OutputStream out = null; try { fs.mkdir(dir); - out = fs.openOutputStream(temp); - request.writeContent(new com.codename1.io.filechooser.FileDialogResult( - request, new String[]{temp}, -1, null), out); - out.close(); - out = null; + java.io.OutputStream out = fs.openOutputStream(temp); + try { + request.writeContent(new com.codename1.io.filechooser.FileDialogResult( + request, new String[]{temp}, -1, null), out); + } finally { + out.close(); + } } catch (final IOException err) { - com.codename1.io.Util.cleanup(out); fs.delete(temp); Display.getInstance().callSerially(new Runnable() { @Override @@ -6951,7 +6951,7 @@ private void showMacDialog(com.codename1.io.filechooser.FileDialogRequest req String name = save ? ((com.codename1.io.filechooser.SaveFileRequest) request).getSuggestedName() : null; nativeInstance.showMacFileDialog(save, request.getTitle(), request.getMessage(), request.getAcceptLabel(), com.codename1.impl.NativeFileDialogCodec.toNativePath(request.getInitialDirectory()), name, - joinRecords(com.codename1.impl.NativeFileDialogCodec.encodeFilters(request)), + joinRecords(com.codename1.impl.NativeFileDialogCodec.encodeFilters(request, true)), request.getSelectedFilter(), joinRecords(com.codename1.impl.NativeFileDialogCodec.encodeOptions(request)), multi, dirs, From 02eb1bc9c1bb0e94eb4b82948074fe9d3a1d2543 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:22:27 +0300 Subject: [PATCH 15/31] File dialogs: address the third review round - Windows: an IFileDialogEvents sink keeps the save dialog's default extension on the selected filter, so switching type no longer yields report.pdf.png. - Core: the fallback save form runs on the EDT when called from another thread, and the open fallback delivers on the EDT. - JavaSE: drop a preview render that finishes after the selection moved; parse file:/ and drive-letter URLs for the starting folder; saves that must not confirm an overwrite use the Swing chooser. - iOS: watchOS and tvOS use the Codename One fallback; a picker requested while another controller is presented answers cancelled instead of hanging. Co-Authored-By: Claude Opus 5.5 --- .../com/codename1/impl/FileDialogSupport.java | 16 ++++- .../impl/javase/JavaSEFileDialogs.java | 22 ++++++- .../nativeSources/cn1_windows_filedialog.cpp | 61 ++++++++++++++++++- Ports/iOSPort/nativeSources/IOSNative.m | 12 +++- .../codename1/impl/ios/IOSImplementation.java | 13 ++++ 5 files changed, 118 insertions(+), 6 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/FileDialogSupport.java b/CodenameOne/src/com/codename1/impl/FileDialogSupport.java index b282663c1e7..0d3b1898e52 100644 --- a/CodenameOne/src/com/codename1/impl/FileDialogSupport.java +++ b/CodenameOne/src/com/codename1/impl/FileDialogSupport.java @@ -64,10 +64,11 @@ static void showOpen(CodenameOneImplementation impl, final OpenFileRequest reque @Override public void actionPerformed(ActionEvent evt) { Object src = evt == null ? null : evt.getSource(); + // A port may answer openFileChooser off the EDT; the API promises the EDT. if (src instanceof String) { - callback.onSucess(new FileDialogResult(request, new String[]{(String) src}, -1, null)); + deliver(new FileDialogResult(request, new String[]{(String) src}, -1, null), callback); } else { - callback.onSucess(FileDialogResult.cancelled(request)); + deliver(FileDialogResult.cancelled(request), callback); } } }, request.toAcceptString()); @@ -77,6 +78,17 @@ public void actionPerformed(ActionEvent evt) { /// request's options, saving into its initial directory or app home. static void showSave(final CodenameOneImplementation impl, final SaveFileRequest request, final SuccessCallback callback) { + if (!Display.getInstance().isEdt()) { + // The form is modal and must be built on the EDT, which also puts + // the cancellation below on the EDT the API promises. + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + showSave(impl, request, callback); + } + }); + return; + } final FileSystemStorage fs = FileSystemStorage.getInstance(); String dir = request.getInitialDirectory(); if (dir == null || !fs.isDirectory(dir)) { diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEFileDialogs.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEFileDialogs.java index f636e018c2b..65fda7c3ce3 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEFileDialogs.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEFileDialogs.java @@ -113,6 +113,10 @@ private static boolean needsSwing(FileDialogRequest request) { || request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.DESKTOP_SHOW_HIDDEN, false)) { return true; } + if (request instanceof SaveFileRequest && !((SaveFileRequest) request).isConfirmOverwrite()) { + // The OS panels behind AWT's FileDialog always ask before replacing + return true; + } if (request.getFilters().length > 0) { // A filtered save adds the filter's extension after the panel has // closed, so the panel's overwrite check saw a different name; the @@ -148,8 +152,15 @@ private static File toFile(String path) { if (path == null) { return null; } - if (path.startsWith("file://")) { - path = path.substring(7); + if (path.startsWith("file:")) { + // file:///x and the file:/x that getAppHomePath() answers + path = path.substring(5); + while (path.startsWith("//")) { + path = path.substring(1); + } + if (path.length() > 2 && path.charAt(0) == '/' && path.charAt(2) == ':') { + path = path.substring(1); // /C:/dir + } } return new File(path); } @@ -344,9 +355,13 @@ private static void installPreview(JFileChooser chooser, final FilePreviewProvid holder.add(chooser.getAccessory(), BorderLayout.SOUTH); } chooser.setAccessory(holder); + // Selection number, read and written only on the AWT thread: a render + // that finishes after the selection moved on is dropped. + final int[] generation = new int[1]; chooser.addPropertyChangeListener(JFileChooser.SELECTED_FILE_CHANGED_PROPERTY, new PropertyChangeListener() { public void propertyChange(PropertyChangeEvent evt) { final File f = (File) evt.getNewValue(); + final int current = ++generation[0]; label.setIcon(null); if (f == null || f.isDirectory()) { return; @@ -356,6 +371,9 @@ public void run() { final java.awt.Image img = renderPreview(provider, toPath(f)); EventQueue.invokeLater(new Runnable() { public void run() { + if (current != generation[0]) { + return; + } label.setIcon(img == null ? null : new ImageIcon(img)); } }); diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_filedialog.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_filedialog.cpp index afd33ee8328..0027657a9f5 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_filedialog.cpp +++ b/Ports/WindowsPort/nativeSources/cn1_windows_filedialog.cpp @@ -196,6 +196,50 @@ IShellItem* cn1FdxItem(const WCHAR* path) { } /* Builds the options panel through IFileDialogCustomize. */ +/* Keeps a save dialog's default extension on the selected filter. The shell + * appends SetDefaultExtension's value to a bare name, so without this a user + * who switches from PDF to PNG and types "report" gets report.pdf, which the + * Java side then turns into report.pdf.png. */ +class Cn1FdxEvents : public IFileDialogEvents { +public: + Cn1FdxEvents(Cn1FdxRequest* r) : refs(1), req(r) {} + virtual ~Cn1FdxEvents() {} + IFACEMETHODIMP QueryInterface(REFIID riid, void** ppv) { + if (riid == IID_IUnknown || riid == IID_IFileDialogEvents) { + *ppv = static_cast(this); + AddRef(); + return S_OK; + } + *ppv = NULL; + return E_NOINTERFACE; + } + IFACEMETHODIMP_(ULONG) AddRef() { return (ULONG) InterlockedIncrement(&refs); } + IFACEMETHODIMP_(ULONG) Release() { + LONG n = InterlockedDecrement(&refs); + if (n == 0) { + delete this; + } + return (ULONG) n; + } + IFACEMETHODIMP OnTypeChange(IFileDialog* dlg) { + UINT index = 0; + if (SUCCEEDED(dlg->GetFileTypeIndex(&index)) && index >= 1 && (int) index <= req->filterCount) { + Cn1FdxSplit* f = &req->filters[index - 1]; + dlg->SetDefaultExtension(f->count > 1 && wcscmp(f->fields[1], L"*") != 0 ? f->fields[1] : L""); + } + return S_OK; + } + IFACEMETHODIMP OnFileOk(IFileDialog*) { return S_OK; } + IFACEMETHODIMP OnFolderChanging(IFileDialog*, IShellItem*) { return S_OK; } + IFACEMETHODIMP OnFolderChange(IFileDialog*) { return S_OK; } + IFACEMETHODIMP OnSelectionChange(IFileDialog*) { return S_OK; } + IFACEMETHODIMP OnShareViolation(IFileDialog*, IShellItem*, FDE_SHAREVIOLATION_RESPONSE*) { return S_OK; } + IFACEMETHODIMP OnOverwrite(IFileDialog*, IShellItem*, FDE_OVERWRITE_RESPONSE*) { return S_OK; } +private: + LONG refs; + Cn1FdxRequest* req; +}; + void cn1FdxAddOptions(IFileDialog* dlg, Cn1FdxRequest* r) { IFileDialogCustomize* cust = NULL; int i, k; @@ -395,7 +439,22 @@ void cn1FdxRun(Cn1FdxRequest* r) { } cn1FdxAddOptions(dlg, r); - if (SUCCEEDED(dlg->Show(cn1Win.hwnd))) { + DWORD eventsCookie = 0; + Cn1FdxEvents* events = NULL; + if (r->save && r->filterCount > 1) { + events = new Cn1FdxEvents(r); + if (FAILED(dlg->Advise(events, &eventsCookie))) { + eventsCookie = 0; + } + } + HRESULT shown = dlg->Show(cn1Win.hwnd); + if (events != NULL) { + if (eventsCookie != 0) { + dlg->Unadvise(eventsCookie); + } + events->Release(); + } + if (SUCCEEDED(shown)) { UINT typeIndex = 0; r->accepted = 1; r->chosenFilter = -1; diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 84bbb87e71f..35defe2dd93 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -9325,7 +9325,17 @@ static void cn1FileDialogPresent(UIDocumentPickerViewController *picker, JAVA_IN if (popoverSupported()) { picker.modalPresentationStyle = UIModalPresentationFormSheet; } - [cn1PresentingController() presentViewController:picker animated:YES completion:nil]; + UIViewController *presenter = cn1PresentingController(); + if (presenter.presentedViewController != nil) { + // UIKit does not queue a second presentation: it would be dropped + // with no delegate call and this request would never answer. Report + // it as cancelled instead (an earlier picker or another modal is up). + NSLog(@"Codename One file dialog: another view controller is already presented"); + delegate.delivered = YES; + cn1FileDialogDeliver(nil, requestId); + return; + } + [presenter presentViewController:picker animated:YES completion:nil]; } static NSArray *cn1FileDialogSplit(NSString *joined) { diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index fafd2ee63db..39435b492a1 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -6820,6 +6820,10 @@ private static String stripFileScheme(String path) { @Override public boolean isFileDialogFeatureSupported(int feature) { + if (isWatch() || isTV()) { + // No document picker on either; the Codename One fallback is used + return super.isFileDialogFeatureSupported(feature); + } if (isMacPlatform()) { // No accessory Component or preview: there is no way to host a // Codename One peer inside the panel's accessory NSView yet. @@ -6835,6 +6839,11 @@ public boolean isFileDialogFeatureSupported(int feature) { @Override public void showOpenDialog(com.codename1.io.filechooser.OpenFileRequest request, SuccessCallback callback) { + if (isWatch() || isTV()) { + // The document picker natives are compiled out of both targets + super.showOpenDialog(request, callback); + return; + } if (isMacPlatform()) { showMacDialog(request, false, request.isMultiSelect(), request.isDirectories(), callback); return; @@ -6872,6 +6881,10 @@ public void showOpenDialog(com.codename1.io.filechooser.OpenFileRequest request, @Override public void showSaveDialog(final com.codename1.io.filechooser.SaveFileRequest request, final SuccessCallback callback) { + if (isWatch() || isTV()) { + super.showSaveDialog(request, callback); + return; + } if (isMacPlatform()) { // NSSavePanel always confirms an overwrite itself; AppKit has no // public switch for it, so isConfirmOverwrite(false) is not honoured. From 9f1f2aeb58fc99b81979b402ab99dc2fbadf7e37 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:06:10 +0300 Subject: [PATCH 16/31] File dialogs: address the fourth review round - Windows: the events sink sets the default share-violation and overwrite responses instead of leaving them indeterminate. - Core: drop the WEB_ID and WEB_START_IN hints; the browser port uses a download and a file input, neither of which reads them. - iOS: escape '%' and newline in picker paths before joining them, and unescape in fileDialogResult. - Android: siblings with the same display name in a copied folder get distinct local names. Co-Authored-By: Claude Opus 5.5 --- .../io/filechooser/FileDialogHints.java | 6 ----- .../impl/android/AndroidFileDialogs.java | 23 ++++++++++++++++++- .../nativeSources/cn1_windows_filedialog.cpp | 12 ++++++++-- Ports/iOSPort/nativeSources/IOSNative.m | 19 +++++++++++++-- .../codename1/impl/ios/IOSImplementation.java | 8 +++++-- .../io/filechooser/FileDialogRequestTest.java | 4 ++-- 6 files changed, 57 insertions(+), 15 deletions(-) diff --git a/CodenameOne/src/com/codename1/io/filechooser/FileDialogHints.java b/CodenameOne/src/com/codename1/io/filechooser/FileDialogHints.java index 2e051528fc6..f1b85e8c1fe 100644 --- a/CodenameOne/src/com/codename1/io/filechooser/FileDialogHints.java +++ b/CodenameOne/src/com/codename1/io/filechooser/FileDialogHints.java @@ -54,12 +54,6 @@ public final class FileDialogHints { /// `String`: the folder Windows shows when the user has no recent folder, /// as opposed to `FileDialogRequest#initialDirectory`, which always wins. public static final String WIN_DEFAULT_FOLDER = "win.defaultFolder"; - /// `String`: an id under which the browser remembers the last directory - /// (`showSaveFilePicker`'s `id`). - public static final String WEB_ID = "web.id"; - /// `String`: a well-known browser directory to start in: `desktop`, - /// `documents`, `downloads`, `music`, `pictures` or `videos`. - public static final String WEB_START_IN = "web.startIn"; /// `Boolean`: show hidden files in the GTK chooser and the simulator. public static final String DESKTOP_SHOW_HIDDEN = "desktop.showHidden"; diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java b/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java index 386a0752dc7..5bada9bb869 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java @@ -464,7 +464,7 @@ private static void copyTreeChildren(ContentResolver resolver, Uri tree, String cursor.close(); } for (String[] e : entries) { - File child = new File(dest, safeName(e[1], "document")); + File child = uniqueChild(dest, safeName(e[1], "document")); if (android.provider.DocumentsContract.Document.MIME_TYPE_DIR.equals(e[2])) { copyTreeChildren(resolver, tree, e[0], child); } else { @@ -473,6 +473,27 @@ private static void copyTreeChildren(ContentResolver resolver, Uri tree, String } } + /* + * A provider (a cloud drive, typically) may list two siblings under one + * display name. Each gets its own local name, "name (2).ext" and so on, + * rather than the second overwriting the first or merging into it. + */ + private static File uniqueChild(File dir, String name) { + File f = new File(dir, name); + if (!f.exists()) { + return f; + } + int dot = name.lastIndexOf('.'); + String base = dot > 0 ? name.substring(0, dot) : name; + String ext = dot > 0 ? name.substring(dot) : ""; + for (int n = 2; ; n++) { + f = new File(dir, base + " (" + n + ")" + ext); + if (!f.exists()) { + return f; + } + } + } + private static String copyToCache(ContentResolver resolver, Uri uri) throws IOException { File dest = new File(pickDirectory(uri), safeName(displayName(resolver, uri), "document")); copyStream(resolver, uri, dest); diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_filedialog.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_filedialog.cpp index 0027657a9f5..d645317b058 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_filedialog.cpp +++ b/Ports/WindowsPort/nativeSources/cn1_windows_filedialog.cpp @@ -233,8 +233,16 @@ class Cn1FdxEvents : public IFileDialogEvents { IFACEMETHODIMP OnFolderChanging(IFileDialog*, IShellItem*) { return S_OK; } IFACEMETHODIMP OnFolderChange(IFileDialog*) { return S_OK; } IFACEMETHODIMP OnSelectionChange(IFileDialog*) { return S_OK; } - IFACEMETHODIMP OnShareViolation(IFileDialog*, IShellItem*, FDE_SHAREVIOLATION_RESPONSE*) { return S_OK; } - IFACEMETHODIMP OnOverwrite(IFileDialog*, IShellItem*, FDE_OVERWRITE_RESPONSE*) { return S_OK; } + /* Only the type change matters here; both conflicts keep the shell's own + * handling, which needs the response set, not left indeterminate. */ + IFACEMETHODIMP OnShareViolation(IFileDialog*, IShellItem*, FDE_SHAREVIOLATION_RESPONSE* response) { + *response = FDESVR_DEFAULT; + return S_OK; + } + IFACEMETHODIMP OnOverwrite(IFileDialog*, IShellItem*, FDE_OVERWRITE_RESPONSE* response) { + *response = FDEOR_DEFAULT; + return S_OK; + } private: LONG refs; Cn1FdxRequest* req; diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 35defe2dd93..f08e3482198 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -9250,6 +9250,21 @@ static void cn1FileDialogDeliver(NSString *paths, JAVA_INT requestId) { return result; } +// Newline separates the paths handed to fileDialogResult, and a file name may +// legally contain one, so each path is escaped first ('%' before '\n', so a +// literal "%0a" stays literal). IOSImplementation.fileDialogResult undoes it. +static NSString *cn1FileDialogJoin(NSArray *paths) { + if ([paths count] == 0) { + return nil; + } + NSMutableArray *escaped = [NSMutableArray arrayWithCapacity:[paths count]]; + for (NSString *p in paths) { + NSString *e = [p stringByReplacingOccurrencesOfString:@"%" withString:@"%25"]; + [escaped addObject:[e stringByReplacingOccurrencesOfString:@"\n" withString:@"%0a"]]; + } + return [escaped componentsJoinedByString:@"\n"]; +} + @interface CN1FileDialogDelegate : NSObject @property (nonatomic, assign) JAVA_INT requestId; @property (nonatomic, assign) BOOL exporting; @@ -9276,7 +9291,7 @@ - (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocum [paths addObject:[url path]]; } } - [self finish:controller paths:[paths count] == 0 ? nil : [paths componentsJoinedByString:@"\n"]]; + [self finish:controller paths:cn1FileDialogJoin(paths)]; return; } // Delegate callbacks run on the main thread, and an import may copy a @@ -9291,7 +9306,7 @@ - (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocum [paths addObject:p]; } } - NSString *joined = [paths count] == 0 ? nil : [paths componentsJoinedByString:@"\n"]; + NSString *joined = cn1FileDialogJoin(paths); dispatch_async(dispatch_get_main_queue(), ^{ [self finish:controller paths:joined]; }); diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 39435b492a1..abed15e0a15 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -7035,9 +7035,13 @@ public void run() { if (paths == null || paths.length() == 0) { r = com.codename1.io.filechooser.FileDialogResult.cancelled(pending.request); } else { + // The native escapes '%' and newline in each path (cn1FileDialogJoin) java.util.List list = StringUtil.tokenize(paths, '\n'); - r = new com.codename1.io.filechooser.FileDialogResult(pending.request, - list.toArray(new String[list.size()]), -1, null); + String[] arr = new String[list.size()]; + for (int i = 0; i < arr.length; i++) { + arr[i] = StringUtil.replaceAll(StringUtil.replaceAll(list.get(i), "%0a", "\n"), "%25", "%"); + } + r = new com.codename1.io.filechooser.FileDialogResult(pending.request, arr, -1, null); } if (pending.tempFile != null) { // Exported as a copy or moved away; either way the staging diff --git a/maven/core-unittests/src/test/java/com/codename1/io/filechooser/FileDialogRequestTest.java b/maven/core-unittests/src/test/java/com/codename1/io/filechooser/FileDialogRequestTest.java index 1d47d46127b..62c54ce7525 100644 --- a/maven/core-unittests/src/test/java/com/codename1/io/filechooser/FileDialogRequestTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/io/filechooser/FileDialogRequestTest.java @@ -68,12 +68,12 @@ void selectedFilterIsClamped() { void hintsAreTyped() { OpenFileRequest r = new OpenFileRequest() .hint(FileDialogHints.MAC_SHOWS_HIDDEN_FILES, Boolean.TRUE) - .hint(FileDialogHints.WEB_START_IN, "documents") + .hint(FileDialogHints.WIN_DEFAULT_FOLDER, "documents") .hint("x.flag", "TRUE"); assertTrue(r.getBooleanHint(FileDialogHints.MAC_SHOWS_HIDDEN_FILES, false)); assertTrue(r.getBooleanHint("x.flag", false)); assertTrue(r.getBooleanHint("missing", true)); - assertEquals("documents", r.getStringHint(FileDialogHints.WEB_START_IN)); + assertEquals("documents", r.getStringHint(FileDialogHints.WIN_DEFAULT_FOLDER)); assertNull(r.getStringHint("missing")); } From bfa84c5a674c548e116bd56c000e724a51fc555c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:28:40 +0300 Subject: [PATCH 17/31] File dialogs: address the fifth review round - macOS: escape '%' and the record separator in every result field, since a Mac file name may contain the separator; macFileDialogResult unescapes. - JavaSE: requests with a custom accept label use the Swing chooser, the only one that can set the button text. - iOS: an import whose copy fails reports an error instead of silently dropping the item or reading as a cancellation. Co-Authored-By: Claude Opus 5.5 --- .../impl/javase/JavaSEFileDialogs.java | 4 +++ Ports/iOSPort/nativeSources/IOSNative.m | 30 +++++++++++++++---- .../codename1/impl/ios/IOSImplementation.java | 8 ++++- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEFileDialogs.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEFileDialogs.java index 65fda7c3ce3..3de8950c152 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEFileDialogs.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEFileDialogs.java @@ -113,6 +113,10 @@ private static boolean needsSwing(FileDialogRequest request) { || request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.DESKTOP_SHOW_HIDDEN, false)) { return true; } + if (request.getAcceptLabel() != null) { + // FileDialog has no way to set the button text; JFileChooser does + return true; + } if (request instanceof SaveFileRequest && !((SaveFileRequest) request).isConfirmOverwrite()) { // The OS panels behind AWT's FileDialog always ask before replacing return true; diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index f08e3482198..aef0318f895 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -9300,13 +9300,24 @@ - (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocum // the blocks keep the controller and the URLs alive until it is done. dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ NSMutableArray *paths = [NSMutableArray array]; + BOOL copyFailed = NO; + NSString *failed = nil; for (NSURL *url in urls) { NSString *p = cn1FileDialogCopyToTemp(url); - if (p != nil) { - [paths addObject:p]; + if (p == nil) { + copyFailed = YES; + failed = [url lastPathComponent]; + break; } - } - NSString *joined = cn1FileDialogJoin(paths); + [paths addObject:p]; + } + // A copy that failed (a cloud download, a full disk) fails the whole + // import rather than quietly dropping the item. Paths are absolute, so + // a leading '!' can only be this marker; fileDialogResult reads the + // rest as the error message. + NSString *joined = copyFailed + ? [NSString stringWithFormat:@"!Could not import %@", failed == nil ? @"the selected item" : failed] + : cn1FileDialogJoin(paths); dispatch_async(dispatch_get_main_queue(), ^{ [self finish:controller paths:joined]; }); @@ -9670,7 +9681,16 @@ void com_codename1_impl_ios_IOSNative_showMacFileDialog___boolean_java_lang_Stri NSArray *urls = [panel isKindOfClass:[NSOpenPanel class]] ? ((NSOpenPanel *)panel).URLs : (panel.URL == nil ? [NSArray array] : [NSArray arrayWithObject:panel.URL]); for (NSURL *u in urls) [out addObject:[u path]]; - if (urls.count > 0) answer = [out componentsJoinedByString:@"\x1e"]; + if (urls.count > 0) { + // A Mac file name may contain the separator itself, so every + // field is escaped ('%' first); macFileDialogResult undoes it. + NSMutableArray *escaped = [NSMutableArray arrayWithCapacity:out.count]; + for (NSString *f in out) { + NSString *e = [f stringByReplacingOccurrencesOfString:@"%" withString:@"%25"]; + [escaped addObject:[e stringByReplacingOccurrencesOfString:@"\x1e" withString:@"%1e"]]; + } + answer = [escaped componentsJoinedByString:@"\x1e"]; + } } // The popup holds its target weakly; this capture keeps it alive // until the panel closes. diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index abed15e0a15..ffa4819bf3d 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -6996,7 +6996,9 @@ public void run() { int start = 0; for (int i = 0; i <= answer.length(); i++) { if (i == answer.length() || answer.charAt(i) == MAC_DIALOG_RS) { - list.add(answer.substring(start, i)); + // The native escapes '%' and the separator in each field + list.add(StringUtil.replaceAll(StringUtil.replaceAll( + answer.substring(start, i), "%1e", "\u001e"), "%25", "%")); start = i + 1; } } @@ -7034,6 +7036,10 @@ public void run() { com.codename1.io.filechooser.FileDialogResult r; if (paths == null || paths.length() == 0) { r = com.codename1.io.filechooser.FileDialogResult.cancelled(pending.request); + } else if (paths.charAt(0) == '!') { + // The native could not import a picked item (never a path: paths are absolute) + r = com.codename1.io.filechooser.FileDialogResult.cancelled(pending.request); + r.setError(new IOException(paths.substring(1))); } else { // The native escapes '%' and newline in each path (cn1FileDialogJoin) java.util.List list = StringUtil.tokenize(paths, '\n'); From dea8d47680f760781f82b7362285bf7ce240ffd8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:18:37 +0300 Subject: [PATCH 18/31] File dialogs: fix the iOS warning census and fail mixed Android picks - iOS: the pre-iOS 14 picker path uses the public.folder and public.item UTI strings instead of kUTTypeFolder/kUTTypeItem, which iOS 15 deprecates; the Xcode 27 warning census rejected the new warning. - Android: a multi-selection containing any file the filters reject fails whole with an error, instead of returning the accepted subset. Co-Authored-By: Claude Opus 5.5 --- .../impl/android/AndroidFileDialogs.java | 27 +++++++++++++------ Ports/iOSPort/nativeSources/IOSNative.m | 6 +++-- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java b/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java index 5bada9bb869..cf82614e7a0 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java @@ -144,19 +144,30 @@ public void onActivityResult(int requestCode, int resultCode, Intent data) { public void run() { FileDialogResult result; try { + // Checked before anything is copied: a selection with + // any file the filters reject fails whole, rather than + // handing back a quietly incomplete subset. + boolean rejected = false; + if (!request.isDirectories()) { + for (Uri u : uris) { + if (!acceptedByFilters(request.getFilters(), resolver, u)) { + rejected = true; + break; + } + } + } List paths = new ArrayList(); - for (Uri u : uris) { - if (request.isDirectories()) { + if (!rejected) { + for (Uri u : uris) { // A tree URI is not a path FileSystemStorage can - // list; copy the folder, as iOS does. - paths.add(copyTreeToCache(resolver, u)); - } else if (acceptedByFilters(request.getFilters(), resolver, u)) { - paths.add(copyToCache(resolver, u)); + // list; a folder is copied, as iOS does. + paths.add(request.isDirectories() ? copyTreeToCache(resolver, u) + : copyToCache(resolver, u)); } } - if (paths.isEmpty()) { + if (rejected) { result = FileDialogResult.cancelled(request); - result.setError(new IOException("The selected file does not match the dialog's filters")); + result.setError(new IOException("A selected file does not match the dialog's filters")); } else { result = new FileDialogResult(request, paths.toArray(new String[paths.size()]), -1, null); } diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index aef0318f895..e8d660644cc 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -9421,7 +9421,9 @@ void com_codename1_impl_ios_IOSNative_showOpenFileDialog___java_lang_String_java if (picker == nil) { NSMutableArray *legacy = [NSMutableArray array]; if (directories) { - [legacy addObject:(NSString *)kUTTypeFolder]; + // The stable UTI strings rather than kUTTypeFolder/kUTTypeItem, + // which iOS 15 deprecates (see cn1AddFileChooserDocumentType). + [legacy addObject:@"public.folder"]; } else { [legacy addObjectsFromArray:utList]; // Before iOS 14 there is no UTType class, so an extension-only @@ -9431,7 +9433,7 @@ void com_codename1_impl_ios_IOSNative_showOpenFileDialog___java_lang_String_java cn1AddFileChooserDocumentType(legacy, kUTTagClassFilenameExtension, ext); } if ([legacy count] == 0) { - [legacy addObject:(NSString *)kUTTypeItem]; + [legacy addObject:@"public.item"]; } } picker = [[UIDocumentPickerViewController alloc] initWithDocumentTypes:legacy From 26f0d64a0e84fc661f1644db66d40b8d284b4276 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:00:48 +0300 Subject: [PATCH 19/31] File dialogs: stage aliased saves, cap Linux items, clean iOS staging Co-Authored-By: Claude Opus 5.5 --- .../com/codename1/impl/FileDialogSupport.java | 57 +++++++++++++++++-- .../teavm/ext/usermedia/FileChooser.java | 3 + .../impl/linux/LinuxImplementation.java | 26 +++++++++ Ports/iOSPort/nativeSources/IOSNative.m | 11 ++++ .../codename1/impl/ios/IOSImplementation.java | 16 +++++- 5 files changed, 105 insertions(+), 8 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/FileDialogSupport.java b/CodenameOne/src/com/codename1/impl/FileDialogSupport.java index 0d3b1898e52..24264a1cf7a 100644 --- a/CodenameOne/src/com/codename1/impl/FileDialogSupport.java +++ b/CodenameOne/src/com/codename1/impl/FileDialogSupport.java @@ -25,6 +25,7 @@ import com.codename1.io.FileSystemStorage; import com.codename1.io.Log; +import com.codename1.io.Util; import com.codename1.io.filechooser.FileDialogOption; import com.codename1.io.filechooser.FileDialogResult; import com.codename1.io.filechooser.FileTypeFilter; @@ -46,6 +47,7 @@ import com.codename1.util.SuccessCallback; import java.io.IOException; +import java.io.InputStream; import java.io.OutputStream; import java.util.HashMap; import java.util.Map; @@ -216,11 +218,15 @@ && isSameFile(request.getSourceFile(), result.getPath())) { deliver(result, callback); return; } - OutputStream out = FileSystemStorage.getInstance().openOutputStream(result.getPath()); - try { - request.writeContent(result, out); - } finally { - out.close(); + if (request.getContent() == null) { + copyStaged(request.getSourceFile(), result.getPath()); + } else { + OutputStream out = FileSystemStorage.getInstance().openOutputStream(result.getPath()); + try { + request.writeContent(result, out); + } finally { + out.close(); + } } } catch (IOException err) { Log.e(err); @@ -231,6 +237,47 @@ && isSameFile(request.getSourceFile(), result.getPath())) { }); } + /// Copies a save's source file to its destination through a staged copy. + /// The two can be the same file under different spellings (a drive path + /// against a `file:` URL, case on a case-insensitive volume, `..`, links), + /// which no textual comparison catches; opening the destination first would + /// then truncate the source before it was read. Reading the whole source into + /// a staging file before the destination is opened is safe for every alias. + private static void copyStaged(String source, String destination) throws IOException { + if (source == null) { + throw new IOException("SaveFileRequest has neither content nor a source file"); + } + FileSystemStorage fs = FileSystemStorage.getInstance(); + String home = fs.getAppHomePath(); + String staging = (home.endsWith("/") ? home : home + "/") + + ".cn1-save-" + System.currentTimeMillis() + "-" + (stagingCounter++); + try { + copy(fs, source, staging); + copy(fs, staging, destination); + } finally { + if (fs.exists(staging)) { + fs.delete(staging); + } + } + } + + // Only the single background-task thread touches this, through completeSave. + private static int stagingCounter; + + private static void copy(FileSystemStorage fs, String from, String to) throws IOException { + InputStream in = fs.openInputStream(from); + try { + OutputStream out = fs.openOutputStream(to); + try { + Util.copyNoClose(in, out, 8192); + } finally { + out.close(); + } + } finally { + in.close(); + } + } + private static void deliver(final FileDialogResult result, final SuccessCallback callback) { Display.getInstance().callSerially(new Runnable() { @Override diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/teavm/ext/usermedia/FileChooser.java b/Ports/JavaScriptPort/src/main/java/com/codename1/teavm/ext/usermedia/FileChooser.java index bc13d575bef..7a0b937791c 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/teavm/ext/usermedia/FileChooser.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/teavm/ext/usermedia/FileChooser.java @@ -213,6 +213,9 @@ public void run() { public void showDialog() { Log.p("Showing file dialog"); + // Each showDialog() is a new dialog with a fresh input; the guard only + // stops change and cancel from both answering the same one. + answered = false; createFileInput(); final Window win = Window.current(); diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index e3d0702b791..dbe6aaf33d5 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -2614,6 +2614,9 @@ public void openGallery(com.codename1.ui.events.ActionListener response, int typ @Override public void showOpenDialog(com.codename1.io.filechooser.OpenFileRequest request, com.codename1.util.SuccessCallback callback) { + if (rejectOversized(request, callback)) { + return; + } String[] answer = LinuxNative.fileDialogEx(false, request.getTitle(), request.getAcceptLabel(), com.codename1.impl.NativeFileDialogCodec.toNativePath(request.getInitialDirectory()), null, com.codename1.impl.NativeFileDialogCodec.encodeFilters(request), request.getSelectedFilter(), @@ -2626,6 +2629,9 @@ public void showOpenDialog(com.codename1.io.filechooser.OpenFileRequest request, @Override public void showSaveDialog(com.codename1.io.filechooser.SaveFileRequest request, com.codename1.util.SuccessCallback callback) { + if (rejectOversized(request, callback)) { + return; + } String[] answer = LinuxNative.fileDialogEx(true, request.getTitle(), request.getAcceptLabel(), com.codename1.impl.NativeFileDialogCodec.toNativePath(request.getInitialDirectory()), request.getSuggestedName(), @@ -2642,6 +2648,26 @@ public void showSaveDialog(com.codename1.io.filechooser.SaveFileRequest request, } } + /* cn1_linux_services.c holds filters and options in fixed arrays of + * CN1_FDX_MAX_ITEMS (64). Truncating there would leave the Java decoder + * expecting a value slot per original option, so an accepted dialog would + * decode as a cancel; such a request fails up front with an error instead. */ + private static final int MAX_FILE_DIALOG_ITEMS = 64; + + private static boolean rejectOversized(com.codename1.io.filechooser.FileDialogRequest request, + com.codename1.util.SuccessCallback callback) { + if (request.getFilters().length <= MAX_FILE_DIALOG_ITEMS + && request.getOptions().length <= MAX_FILE_DIALOG_ITEMS) { + return false; + } + com.codename1.io.filechooser.FileDialogResult result = + com.codename1.io.filechooser.FileDialogResult.cancelled(request); + result.setError(new java.io.IOException("A file dialog supports at most " + + MAX_FILE_DIALOG_ITEMS + " filters and " + MAX_FILE_DIALOG_ITEMS + " options")); + com.codename1.impl.NativeFileDialogCodec.deliver(callback, result); + return true; + } + @Override public boolean isFileDialogFeatureSupported(int feature) { return feature != com.codename1.io.filechooser.FileDialogFeatures.ACCESSORY_COMPONENT diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index e8d660644cc..9754739f0c5 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -9240,6 +9240,9 @@ static void cn1FileDialogDeliver(NSString *paths, JAVA_INT requestId) { result = destinationPath; } else { NSLog(@"Codename One file dialog failed to copy selected item: %@", error); + // Nothing is returned for this item, so its staging folder, + // with whatever a partial folder copy left in it, goes now. + [fileManager removeItemAtPath:folder error:nil]; } } } @finally { @@ -9307,6 +9310,14 @@ - (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocum if (p == nil) { copyFailed = YES; failed = [url lastPathComponent]; + // The import fails as a whole, so the items already staged are + // never handed to the app; each lives in its own UUID folder, + // which is removed with it. + NSFileManager *fm = [NSFileManager defaultManager]; + for (NSString *staged in paths) { + [fm removeItemAtPath:[staged stringByDeletingLastPathComponent] error:nil]; + } + [paths removeAllObjects]; break; } [paths addObject:p]; diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index ffa4819bf3d..a841bb63dda 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -6901,16 +6901,26 @@ public void showSaveDialog(final com.codename1.io.filechooser.SaveFileRequest re if (!caches.endsWith("/")) { caches = caches + "/"; } - final String dir = caches + "cn1-export-" + System.currentTimeMillis() + "/"; - final String temp = dir + name; + final String base = caches + "cn1-export-"; + final String fileName = name; // The document picker exports a file that already exists, so the // content is staged first, off the EDT. Display.getInstance().scheduleBackgroundTask(new Runnable() { @Override public void run() { com.codename1.io.FileSystemStorage fs = com.codename1.io.FileSystemStorage.getInstance(); + // Each request gets a folder no other request has. Background + // tasks run one at a time, so choosing an unused name here and + // creating it cannot race another save; a timestamp chosen on + // the caller's thread could repeat within one millisecond. + long stamp = System.currentTimeMillis(); + String folder = base + stamp + "/"; + for (int n = 1; fs.exists(folder); n++) { + folder = base + stamp + "-" + n + "/"; + } + final String temp = folder + fileName; try { - fs.mkdir(dir); + fs.mkdir(folder); java.io.OutputStream out = fs.openOutputStream(temp); try { request.writeContent(new com.codename1.io.filechooser.FileDialogResult( From d7802299d6f51d869f613436fd30118231912729 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:45:31 +0300 Subject: [PATCH 20/31] File dialogs: clean failed Android imports, gate iOS 13 directory, wildcard filters Co-Authored-By: Claude Opus 5.5 --- .../io/filechooser/FileTypeFilter.java | 4 + .../impl/android/AndroidFileDialogs.java | 104 ++++++++++++++---- .../codename1/impl/ios/IOSImplementation.java | 35 +++++- 3 files changed, 121 insertions(+), 22 deletions(-) diff --git a/CodenameOne/src/com/codename1/io/filechooser/FileTypeFilter.java b/CodenameOne/src/com/codename1/io/filechooser/FileTypeFilter.java index aeb1ac09672..d4c9d061f85 100644 --- a/CodenameOne/src/com/codename1/io/filechooser/FileTypeFilter.java +++ b/CodenameOne/src/com/codename1/io/filechooser/FileTypeFilter.java @@ -169,6 +169,10 @@ public boolean acceptsName(String name) { return false; } for (String e : extensions) { + // "*" is the all-files extension the native ports recognize too. + if ("*".equals(e)) { + return true; + } int start = name.length() - e.length() - 1; if (start >= 0 && name.charAt(start) == '.' && name.regionMatches(true, start + 1, e, 0, e.length())) { diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java b/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java index cf82614e7a0..427424c9f40 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java @@ -129,20 +129,15 @@ public void onActivityResult(int requestCode, int resultCode, Intent data) { return; } final ContentResolver resolver = AndroidNativeUtil.getContext().getContentResolver(); - if (persist) { - int flags = Intent.FLAG_GRANT_READ_URI_PERMISSION - | (data.getFlags() & Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - for (Uri u : uris) { - try { - resolver.takePersistableUriPermission(u, flags); - } catch (SecurityException ignored) { - } - } - } + final int grantFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION + | (data.getFlags() & Intent.FLAG_GRANT_WRITE_URI_PERMISSION); // Copying can be slow for cloud documents, keep it off the UI thread new Thread(new Runnable() { public void run() { FileDialogResult result; + // Every cache directory this import creates; a failed + // import returns none of them, so they are removed. + List staged = new ArrayList(); try { // Checked before anything is copied: a selection with // any file the filters reject fails whole, rather than @@ -161,8 +156,8 @@ public void run() { for (Uri u : uris) { // A tree URI is not a path FileSystemStorage can // list; a folder is copied, as iOS does. - paths.add(request.isDirectories() ? copyTreeToCache(resolver, u) - : copyToCache(resolver, u)); + paths.add(request.isDirectories() ? copyTreeToCache(resolver, u, staged) + : copyToCache(resolver, u, staged)); } } if (rejected) { @@ -170,6 +165,17 @@ public void run() { result.setError(new IOException("A selected file does not match the dialog's filters")); } else { result = new FileDialogResult(request, paths.toArray(new String[paths.size()]), -1, null); + // Persisted only once the import succeeded: a grant + // for a selection the app never receives would + // still count against the persisted-grant quota. + if (persist) { + for (Uri u : uris) { + try { + resolver.takePersistableUriPermission(u, grantFlags); + } catch (SecurityException ignored) { + } + } + } } } catch (IOException e) { result = new FileDialogResult(request, null, -1, null); @@ -178,6 +184,11 @@ public void run() { result = new FileDialogResult(request, null, -1, null); result.setError(e); } + if (result.getError() != null) { + for (File f : staged) { + deleteRecursive(f); + } + } deliver(callback, result); } }, "FileDialogCopy").start(); @@ -190,7 +201,7 @@ static void showSave(final SaveFileRequest request, final SuccessCallback= 0) { String fromName = name == null ? null : mimeForExtension(extensionOf(name)); if (fromName != null) { @@ -252,8 +263,19 @@ public void run() { }); } - private static void start(FileDialogRequest request, SuccessCallback callback, - Intent intent, IntentResultListener listener) { + private static void start(final FileDialogRequest request, final SuccessCallback callback, + final Intent intent, final IntentResultListener listener) { + if (!Display.getInstance().isEdt()) { + // The busy check below and the listener registration inside + // startActivityForResult must not interleave with another launch; + // running every launch on the EDT serializes them. + Display.getInstance().callSerially(new Runnable() { + public void run() { + start(request, callback, intent, listener); + } + }); + return; + } if (AndroidNativeUtil.getActivity() == null) { FileDialogResult r = FileDialogResult.cancelled(request); r.setError(new IOException("No activity to show the file dialog")); @@ -329,6 +351,30 @@ private static String[] mimeTypes(FileTypeFilter[] filters, int only) { return out.toArray(new String[out.size()]); } + /* + * One MIME type standing for several, for ACTION_CREATE_DOCUMENT which takes + * a single type: "image/png" and "image/jpeg" become "image/*", so the picker + * still offers image providers. Types of different families have no common + * form and answer null, which the caller resolves from the name or falls + * back to application/octet-stream. + */ + private static String commonMime(String[] mimes) { + String family = null; + for (String m : mimes) { + int slash = m.indexOf('/'); + if (slash <= 0) { + return null; + } + String f = m.substring(0, slash); + if (family == null) { + family = f; + } else if (!family.equals(f)) { + return null; + } + } + return family == null || "*".equals(family) ? null : family + "/*"; + } + private static String mimeForExtension(String ext) { if (ext == null) { return null; @@ -432,6 +478,10 @@ private static String safeName(String name, String fallback) { if (name == null || name.length() == 0) { return fallback; } + // "." and ".." would resolve to the staging folder or its parent. + if (".".equals(name) || "..".equals(name)) { + return fallback; + } return name.replace('/', '_').replace('\\', '_'); } @@ -445,10 +495,12 @@ private static File pickDirectory(Uri uri) throws IOException { return dir; } - private static String copyTreeToCache(ContentResolver resolver, Uri tree) throws IOException { + private static String copyTreeToCache(ContentResolver resolver, Uri tree, List staged) throws IOException { String rootId = android.provider.DocumentsContract.getTreeDocumentId(tree); Uri rootDoc = android.provider.DocumentsContract.buildDocumentUriUsingTree(tree, rootId); - File dest = new File(pickDirectory(tree), safeName(displayName(resolver, rootDoc), "folder")); + File dir = pickDirectory(tree); + staged.add(dir); + File dest = new File(dir, safeName(displayName(resolver, rootDoc), "folder")); copyTreeChildren(resolver, tree, rootId, dest); return "file://" + dest.getAbsolutePath(); } @@ -505,12 +557,26 @@ private static File uniqueChild(File dir, String name) { } } - private static String copyToCache(ContentResolver resolver, Uri uri) throws IOException { - File dest = new File(pickDirectory(uri), safeName(displayName(resolver, uri), "document")); + private static String copyToCache(ContentResolver resolver, Uri uri, List staged) throws IOException { + File dir = pickDirectory(uri); + staged.add(dir); + File dest = new File(dir, safeName(displayName(resolver, uri), "document")); copyStream(resolver, uri, dest); return "file://" + dest.getAbsolutePath(); } + private static void deleteRecursive(File f) { + File[] children = f.listFiles(); + if (children != null) { + for (File c : children) { + deleteRecursive(c); + } + } + if (!f.delete()) { + f.deleteOnExit(); + } + } + private static void copyStream(ContentResolver resolver, Uri uri, File dest) throws IOException { InputStream in = resolver.openInputStream(uri); if (in == null) { diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index a841bb63dda..34450531311 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -6833,7 +6833,36 @@ public boolean isFileDialogFeatureSupported(int feature) { return feature == com.codename1.io.filechooser.FileDialogFeatures.NATIVE || feature == com.codename1.io.filechooser.FileDialogFeatures.MULTI_SELECT || feature == com.codename1.io.filechooser.FileDialogFeatures.DIRECTORIES - || feature == com.codename1.io.filechooser.FileDialogFeatures.INITIAL_DIRECTORY; + // UIDocumentPickerViewController.directoryURL is iOS 13+; the + // native side sets it only there, so older systems ignore it. + || (feature == com.codename1.io.filechooser.FileDialogFeatures.INITIAL_DIRECTORY + && iosMajorVersion() >= 13); + } + + private int iosMajorVersion() { + String ver = nativeInstance.getOSVersion(); + if (ver == null) { + return 0; + } + int dot = ver.indexOf('.'); + try { + return Integer.parseInt(dot < 0 ? ver : ver.substring(0, dot)); + } catch (NumberFormatException e) { + return 0; + } + } + + /// Deletes an export's staged file and the per-request folder holding it. + private static void deleteExportStaging(String temp) { + com.codename1.io.FileSystemStorage fs = com.codename1.io.FileSystemStorage.getInstance(); + fs.delete(temp); + int slash = temp.lastIndexOf('/'); + if (slash > 0) { + String folder = temp.substring(0, slash + 1); + if (fs.exists(folder)) { + fs.delete(folder); + } + } } @Override @@ -6929,7 +6958,7 @@ public void run() { out.close(); } } catch (final IOException err) { - fs.delete(temp); + deleteExportStaging(temp); Display.getInstance().callSerially(new Runnable() { @Override public void run() { @@ -7062,7 +7091,7 @@ public void run() { if (pending.tempFile != null) { // Exported as a copy or moved away; either way the staging // file is no longer needed. - com.codename1.io.FileSystemStorage.getInstance().delete(pending.tempFile); + deleteExportStaging(pending.tempFile); } pending.callback.onSucess(r); } From 0b00046ab959c4764c20dda92282955c92fb8b6d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:10:44 +0300 Subject: [PATCH 21/31] File dialogs: GTK wildcard, unbounded choices, final-path overwrite check; report fallback save controls Co-Authored-By: Claude Opus 5.5 --- .../impl/CodenameOneImplementation.java | 8 +++- .../io/filechooser/FileDialogFeatures.java | 4 ++ .../nativeSources/cn1_linux_services.c | 45 +++++++++++++++---- 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index 275e622692a..1d3b64052b8 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -8943,7 +8943,13 @@ protected void completeSaveDialog(SaveFileRequest request, FileDialogResult resu public boolean isFileDialogFeatureSupported(int feature) { // The default open dialog goes through openFileChooser(), which takes no // starting folder, so INITIAL_DIRECTORY is left to ports that honour it. - return feature == FileDialogFeatures.SAVE_NAME_EDITING; + // The fallback save form (FileDialogSupport.showSave) renders the filter + // list, every portable option and the accessory, so those are reported; + // FileDialogFeatures documents that the fallback open dialog does not. + return feature == FileDialogFeatures.SAVE_NAME_EDITING + || feature == FileDialogFeatures.FILTER_LIST + || feature == FileDialogFeatures.OPTIONS + || feature == FileDialogFeatures.ACCESSORY_COMPONENT; } /// Opens the device image gallery diff --git a/CodenameOne/src/com/codename1/io/filechooser/FileDialogFeatures.java b/CodenameOne/src/com/codename1/io/filechooser/FileDialogFeatures.java index 65117c988a3..2da4e528b2b 100644 --- a/CodenameOne/src/com/codename1/io/filechooser/FileDialogFeatures.java +++ b/CodenameOne/src/com/codename1/io/filechooser/FileDialogFeatures.java @@ -36,6 +36,10 @@ public final class FileDialogFeatures { /// `OpenFileRequest#directories(boolean)` picks folders. public static final int DIRECTORIES = 3; /// More than one `FileTypeFilter` is shown as a user-selectable list. + /// + /// Where `NATIVE` is false, this and `OPTIONS` and `ACCESSORY_COMPONENT` + /// describe the Codename One save form; the fallback open dialog shows + /// none of the three. public static final int FILTER_LIST = 4; /// `FileDialogOption` controls are shown. public static final int OPTIONS = 5; diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_services.c b/Ports/LinuxPort/nativeSources/cn1_linux_services.c index 04bd5ca5405..df426f4dfe5 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_services.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_services.c @@ -779,6 +779,10 @@ static int cn1FdxAddExtension(GSList* paths, const char* filter, int n) { int j; for (j = 1; j < n; j++) { size_t el = strlen(field); + if (strcmp(field, "*") == 0) { + /* the all-files filter accepts any name as typed */ + return 0; + } if (!first) { first = field; } @@ -830,7 +834,10 @@ static void cn1FileDialogExOnMain(void* p) { d->acceptLabel ? d->acceptLabel : (d->save ? "_Save" : "_Open"), GTK_RESPONSE_ACCEPT, NULL); gtk_file_chooser_set_select_multiple(GTK_FILE_CHOOSER(dialog), d->multi && !d->save); gtk_file_chooser_set_show_hidden(GTK_FILE_CHOOSER(dialog), d->showHidden); - gtk_file_chooser_set_do_overwrite_confirmation(GTK_FILE_CHOOSER(dialog), d->confirmOverwrite); + /* GTK would confirm the typed name, before cn1FdxAddExtension changes it, + * and so warn about the wrong file (or miss the real one). The loop below + * confirms the final path itself instead. */ + gtk_file_chooser_set_do_overwrite_confirmation(GTK_FILE_CHOOSER(dialog), FALSE); if (d->initialDir) { gtk_file_chooser_set_current_folder(GTK_FILE_CHOOSER(dialog), d->initialDir); } @@ -845,6 +852,11 @@ static void cn1FileDialogExOnMain(void* p) { filters[i] = gtk_file_filter_new(); gtk_file_filter_set_name(filters[i], n > 0 ? fields[0] : ""); for (j = 1; j < n; j++) { + if (strcmp(fields[j], "*") == 0) { + /* the all-files extension: "*.*" would hide README */ + gtk_file_filter_add_pattern(filters[i], "*"); + continue; + } /* case-insensitive glob, so "png" also matches IMG.PNG */ char glob[256]; int k, o = 0; @@ -876,17 +888,29 @@ static void cn1FileDialogExOnMain(void* p) { * portal can show too; a text option has no choice form and goes into the * extra widget. */ for (i = 0; i < d->optionCount; i++) { - char* fields[CN1_FDX_MAX_ITEMS]; - int n = cn1FdxSplit(d->options[i], fields, CN1_FDX_MAX_ITEMS - 1); + /* Sized from the spec itself: a choice option may carry any number of + * choices, and a fixed array would silently drop the extra ones. */ + int max = 1; + const char* c; + char** fields; + int n; + for (c = d->options[i]; c && *c; c++) { + if (*c == '\n') { + max++; + } + } + fields = g_new0(char*, max); + n = cn1FdxSplit(d->options[i], fields, max); entries[i] = 0; if (n < 4) { + g_free(fields); continue; } if (fields[0][0] == '1') { gtk_file_chooser_add_choice(GTK_FILE_CHOOSER(dialog), fields[1], fields[2], NULL, NULL); gtk_file_chooser_set_choice(GTK_FILE_CHOOSER(dialog), fields[1], fields[3]); } else if (fields[0][0] == '2') { - const char* ids[CN1_FDX_MAX_ITEMS]; + const char** ids = g_new0(const char*, n - 3); int k; for (k = 4; k < n; k++) { ids[k - 4] = fields[k]; @@ -894,6 +918,7 @@ static void cn1FileDialogExOnMain(void* p) { ids[n - 4] = NULL; gtk_file_chooser_add_choice(GTK_FILE_CHOOSER(dialog), fields[1], fields[2], ids, ids); gtk_file_chooser_set_choice(GTK_FILE_CHOOSER(dialog), fields[1], fields[3]); + g_free(ids); } else { GtkWidget* label = gtk_label_new(fields[2]); if (!extra) { @@ -904,6 +929,7 @@ static void cn1FileDialogExOnMain(void* p) { gtk_box_pack_start(GTK_BOX(extra), label, FALSE, FALSE, 0); gtk_box_pack_start(GTK_BOX(extra), entries[i], FALSE, FALSE, 0); } + g_free(fields); } if (extra) { gtk_widget_show_all(extra); @@ -930,13 +956,14 @@ static void cn1FileDialogExOnMain(void* p) { d->values[i] = v ? strdup(v) : 0; } d->paths = gtk_file_chooser_get_filenames(GTK_FILE_CHOOSER(dialog)); - if (d->save && d->paths && d->chosenFilter >= 0 - && cn1FdxAddExtension(d->paths, d->filters[d->chosenFilter], filterFields[d->chosenFilter]) - && d->confirmOverwrite + if (d->save && d->paths && d->chosenFilter >= 0) { + cn1FdxAddExtension(d->paths, d->filters[d->chosenFilter], filterFields[d->chosenFilter]); + } + if (d->save && d->paths && d->confirmOverwrite && g_file_test((const char*) d->paths->data, G_FILE_TEST_EXISTS) && !cn1FdxConfirmReplace(dialog, (const char*) d->paths->data)) { - /* GTK confirmed the typed name, not the one with the extension - * added; the user declined replacing that one, so ask again */ + /* confirmed against the final, extension-adjusted path; the user + * declined replacing it, so the dialog is shown again */ for (i = 0; i < d->optionCount; i++) { free(d->values[i]); d->values[i] = 0; From d5f494aaaeda3a959123848935c9d08ff80f2730 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:19:41 +0300 Subject: [PATCH 22/31] File dialogs: self-contained doc snippets, wildcard accept, iOS 11 multi-select, boolean hint default Co-Authored-By: Claude Opus 5.5 --- .../codename1/io/filechooser/FileDialogRequest.java | 7 ++++++- .../com/codename1/io/filechooser/OpenFileRequest.java | 4 ++-- .../com/codename1/io/filechooser/SaveFileRequest.java | 4 ++-- .../com/codename1/impl/html5/HTML5Implementation.java | 10 +++++++++- .../src/com/codename1/impl/ios/IOSImplementation.java | 4 +++- 5 files changed, 22 insertions(+), 7 deletions(-) diff --git a/CodenameOne/src/com/codename1/io/filechooser/FileDialogRequest.java b/CodenameOne/src/com/codename1/io/filechooser/FileDialogRequest.java index bda03d8accf..1355995ff9a 100644 --- a/CodenameOne/src/com/codename1/io/filechooser/FileDialogRequest.java +++ b/CodenameOne/src/com/codename1/io/filechooser/FileDialogRequest.java @@ -284,7 +284,12 @@ public boolean getBooleanHint(String key, boolean def) { return ((Boolean) o).booleanValue(); } if (o instanceof String) { - return "true".equalsIgnoreCase((String) o); + if ("true".equalsIgnoreCase((String) o)) { + return true; + } + if ("false".equalsIgnoreCase((String) o)) { + return false; + } } return def; } diff --git a/CodenameOne/src/com/codename1/io/filechooser/OpenFileRequest.java b/CodenameOne/src/com/codename1/io/filechooser/OpenFileRequest.java index 93973405624..b2cd8bdcd7b 100644 --- a/CodenameOne/src/com/codename1/io/filechooser/OpenFileRequest.java +++ b/CodenameOne/src/com/codename1/io/filechooser/OpenFileRequest.java @@ -31,11 +31,11 @@ /// .title("Import photos") /// .multiSelect(true) /// .addFilter(new FileTypeFilter("Images").extensions("png", "jpg").mimeTypes("image/*")) -/// .preview(path -> new ScaleImageLabel(loadThumbnail(path))), +/// .preview(path -> new Label(path)), /// result -> { /// if (!result.isCancelled()) { /// for (String p : result.getPaths()) { -/// importFile(p); +/// Log.p("Picked " + p); /// } /// } /// }); diff --git a/CodenameOne/src/com/codename1/io/filechooser/SaveFileRequest.java b/CodenameOne/src/com/codename1/io/filechooser/SaveFileRequest.java index 85c0d00d6a8..ebeb376df56 100644 --- a/CodenameOne/src/com/codename1/io/filechooser/SaveFileRequest.java +++ b/CodenameOne/src/com/codename1/io/filechooser/SaveFileRequest.java @@ -43,8 +43,8 @@ /// .addFilter(new FileTypeFilter("PDF").extensions("pdf").mimeTypes("application/pdf")) /// .addFilter(new FileTypeFilter("Plain text").extensions("txt").mimeTypes("text/plain")) /// .addOption(FileDialogOption.checkbox("charts", "Include charts", true)) -/// .content((result, out) -> report.export(result.getSelectedFilter(), -/// "true".equals(result.getOptionValue("charts")), out)), +/// .content((result, out) -> out.write(("Charts: " + result.getOptionValue("charts")) +/// .getBytes("UTF-8"))), /// result -> { /// if (result.getError() != null) { /// ToastBar.showErrorMessage("Export failed"); diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java index b8429980993..ffa8e9e4546 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java @@ -6594,8 +6594,16 @@ public void openGallery(ActionListener response, int type) { public void showOpenDialog(final com.codename1.io.filechooser.OpenFileRequest request, final com.codename1.util.SuccessCallback callback) { StringBuilder accept = new StringBuilder(); + boolean allFiles = false; for (com.codename1.io.filechooser.FileTypeFilter f : request.getFilters()) { for (String e : f.getExtensions()) { + // "*" is the all-files extension, but ".*" in accept names one + // literal extension; a filter that takes everything means the + // picker must not be narrowed at all. + if ("*".equals(e)) { + allFiles = true; + continue; + } accept.append(accept.length() == 0 ? "." : ",.").append(e); } for (String m : f.getMimeTypes()) { @@ -6614,7 +6622,7 @@ public void actionPerformed(ActionEvent evt) { } callback.onSucess(new com.codename1.io.filechooser.FileDialogResult(request, paths, -1, null)); } - }, accept.length() == 0 ? null : accept.toString(), null); + }, allFiles || accept.length() == 0 ? null : accept.toString(), null); chooser.setMultiple(request.isMultiSelect()); chooser.showDialog(); } diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 34450531311..84244b44d99 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -6831,7 +6831,9 @@ public boolean isFileDialogFeatureSupported(int feature) { && feature != com.codename1.io.filechooser.FileDialogFeatures.PREVIEW; } return feature == com.codename1.io.filechooser.FileDialogFeatures.NATIVE - || feature == com.codename1.io.filechooser.FileDialogFeatures.MULTI_SELECT + // allowsMultipleSelection is iOS 11+; IOSNative.m sets it only there. + || (feature == com.codename1.io.filechooser.FileDialogFeatures.MULTI_SELECT + && iosMajorVersion() >= 11) || feature == com.codename1.io.filechooser.FileDialogFeatures.DIRECTORIES // UIDocumentPickerViewController.directoryURL is iOS 13+; the // native side sets it only there, so older systems ignore it. From 2f69b8e950a213c242a51e7b01ebb96009deb452 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:34:07 +0300 Subject: [PATCH 23/31] File dialogs: wildcard default extension, iOS export name and folder gate, JavaSE URL dirs, Android partial-save cleanup Co-Authored-By: Claude Opus 5.5 --- .../impl/android/AndroidFileDialogs.java | 15 +++++++++++++ .../impl/javase/JavaSEFileDialogs.java | 14 ++++++++++++ .../nativeSources/cn1_linux_services.c | 3 ++- .../nativeSources/cn1_windows_filedialog.cpp | 3 ++- .../codename1/impl/ios/IOSImplementation.java | 22 +++++++++++++++++-- 5 files changed, 53 insertions(+), 4 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java b/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java index 427424c9f40..5abd625edc5 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java @@ -256,6 +256,21 @@ public void run() { } } } + if (result.getError() != null) { + // ACTION_CREATE_DOCUMENT already created the document; + // a failed write leaves it empty or partial, and the + // content:// path cannot be deleted through + // FileSystemStorage, so it is removed here. + try { + if (Build.VERSION.SDK_INT >= 19) { + android.provider.DocumentsContract.deleteDocument(resolver, uri); + } + } catch (java.io.FileNotFoundException ignored) { + // already gone + } catch (RuntimeException ignored) { + // the provider does not support deletion + } + } deliver(callback, result); } }, "FileDialogSave").start(); diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEFileDialogs.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEFileDialogs.java index 3de8950c152..56e4fb9f600 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEFileDialogs.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEFileDialogs.java @@ -156,6 +156,20 @@ private static File toFile(String path) { if (path == null) { return null; } + // FileSystemStorage's virtual app home, which the port maps to a folder + // under user.home; read as a plain path it would be a relative "home/". + if (path.startsWith("file://home") && JavaSEPort.instance != null) { + return new File(JavaSEPort.instance.unfile(path)); + } + if (path.startsWith("file:") && path.indexOf('%') >= 0) { + // A URL with escapes (getAppHomePath() under "My Documents" answers + // %20): decode it, or the dialog is handed a folder that does not exist. + try { + return new File(new java.net.URI(path)); + } catch (Exception notAUrl) { + // a literal '%' in a plain path; strip the scheme below + } + } if (path.startsWith("file:")) { // file:///x and the file:/x that getAppHomePath() answers path = path.substring(5); diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_services.c b/Ports/LinuxPort/nativeSources/cn1_linux_services.c index df426f4dfe5..293d0d64afd 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_services.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_services.c @@ -786,7 +786,8 @@ static int cn1FdxAddExtension(GSList* paths, const char* filter, int n) { if (!first) { first = field; } - if (len > el + 1 && path[len - el - 1] == '.' + /* >= so a dotfile such as ".gitignore" already counts as carrying it */ + if (len >= el + 1 && path[len - el - 1] == '.' && g_ascii_strcasecmp(path + len - el, field) == 0) { return 0; } diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_filedialog.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_filedialog.cpp index d645317b058..df8d9e1b3e8 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_filedialog.cpp +++ b/Ports/WindowsPort/nativeSources/cn1_windows_filedialog.cpp @@ -426,7 +426,8 @@ void cn1FdxRun(Cn1FdxRequest* r) { if (r->selectedFilter >= 0 && r->selectedFilter < r->filterCount) { Cn1FdxSplit* f = &r->filters[r->selectedFilter]; dlg->SetFileTypeIndex((UINT) r->selectedFilter + 1); /* 1-based */ - if (r->save && f->count > 1) { + /* "*" is the all-files extension, never a suffix to append */ + if (r->save && f->count > 1 && wcscmp(f->fields[1], L"*") != 0) { dlg->SetDefaultExtension(f->fields[1]); } } diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 84244b44d99..1bc0a43a3a0 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -6834,7 +6834,9 @@ public boolean isFileDialogFeatureSupported(int feature) { // allowsMultipleSelection is iOS 11+; IOSNative.m sets it only there. || (feature == com.codename1.io.filechooser.FileDialogFeatures.MULTI_SELECT && iosMajorVersion() >= 11) - || feature == com.codename1.io.filechooser.FileDialogFeatures.DIRECTORIES + // Picking a folder through UIDocumentPickerViewController is iOS 13+. + || (feature == com.codename1.io.filechooser.FileDialogFeatures.DIRECTORIES + && iosMajorVersion() >= 13) // UIDocumentPickerViewController.directoryURL is iOS 13+; the // native side sets it only there, so older systems ignore it. || (feature == com.codename1.io.filechooser.FileDialogFeatures.INITIAL_DIRECTORY @@ -6926,8 +6928,24 @@ public void showSaveDialog(final com.codename1.io.filechooser.SaveFileRequest re if (name == null || name.length() == 0) { name = "Untitled"; } - // A separator in the suggested name would escape the staging folder. + // A separator in the suggested name would escape the staging folder, + // and "." or ".." would name the folder itself or its parent. name = name.replace('/', '_'); + if (".".equals(name) || "..".equals(name)) { + name = "Untitled"; + } + // The picker exports the staged file under exactly this name, and no + // later step adds the selected filter's extension, so it is added here + // with the same rule the desktop ports apply. + com.codename1.io.filechooser.FileTypeFilter[] filters = request.getFilters(); + int selected = request.getSelectedFilter(); + if (selected >= 0 && selected < filters.length) { + com.codename1.io.filechooser.FileTypeFilter f = filters[selected]; + String[] ext = f.getExtensions(); + if (ext.length > 0 && !"*".equals(ext[0]) && !f.acceptsName(name)) { + name = name + "." + ext[0]; + } + } String caches = nativeInstance.getCachesDir(); if (!caches.endsWith("/")) { caches = caches + "/"; From 31852b9dda696719524857c26c78b0926143c17d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 21 Oct 2026 04:31:52 +0300 Subject: [PATCH 24/31] File dialogs: Android save name extension, persist grant only after a successful write Co-Authored-By: Claude Opus 5.5 --- .../impl/android/AndroidFileDialogs.java | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java b/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java index 5abd625edc5..fedac5b67d8 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java @@ -211,6 +211,16 @@ static void showSave(final SaveFileRequest request, final SuccessCallback= 0 && sel < saveFilters.length) { + String[] ext = saveFilters[sel].getExtensions(); + if (ext.length > 0 && !"*".equals(ext[0]) && !saveFilters[sel].acceptsName(name)) { + name = name + "." + ext[0]; + } + } if (name != null) { intent.putExtra(Intent.EXTRA_TITLE, name); } @@ -224,13 +234,6 @@ public void onActivityResult(int requestCode, int resultCode, Intent data) { return; } final ContentResolver resolver = AndroidNativeUtil.getContext().getContentResolver(); - if (persist) { - try { - resolver.takePersistableUriPermission(uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - } catch (SecurityException ignored) { - } - } final FileDialogResult result = new FileDialogResult(request, new String[]{uri.toString()}, -1, null); new Thread(new Runnable() { public void run() { @@ -270,6 +273,14 @@ public void run() { } catch (RuntimeException ignored) { // the provider does not support deletion } + } else if (persist) { + // Persisted only after the write succeeded, so a failed + // save holds no grant against the persisted-URI quota. + try { + resolver.takePersistableUriPermission(uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + } catch (SecurityException ignored) { + } } deliver(callback, result); } From cfb4b397b90678901e21b0cac7018dc2779e1e33 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:01:38 +0300 Subject: [PATCH 25/31] File dialogs: size GTK filter fields from the record Co-Authored-By: Claude Opus 5.5 --- .../nativeSources/cn1_linux_services.c | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_services.c b/Ports/LinuxPort/nativeSources/cn1_linux_services.c index 293d0d64afd..c0e9eb2bc94 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_services.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_services.c @@ -846,9 +846,21 @@ static void cn1FileDialogExOnMain(void* p) { gtk_file_chooser_set_current_name(GTK_FILE_CHOOSER(dialog), d->suggestedName); } for (i = 0; i < d->filterCount; i++) { - char* fields[CN1_FDX_MAX_ITEMS]; - int n = cn1FdxSplit(d->filters[i], fields, CN1_FDX_MAX_ITEMS); + /* Sized from the record, like the option parser below: a filter may + * list any number of extensions, and a fixed array would drop the + * extra ones from both the glob list and cn1FdxAddExtension. */ + int max = 1; + const char* c; + char** fields; + int n; int j; + for (c = d->filters[i]; c && *c; c++) { + if (*c == '\n') { + max++; + } + } + fields = g_new0(char*, max); + n = cn1FdxSplit(d->filters[i], fields, max); filterFields[i] = n; filters[i] = gtk_file_filter_new(); gtk_file_filter_set_name(filters[i], n > 0 ? fields[0] : ""); @@ -881,6 +893,7 @@ static void cn1FileDialogExOnMain(void* p) { gtk_file_filter_add_pattern(filters[i], "*"); } gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(dialog), filters[i]); + g_free(fields); } if (d->filterCount > 0 && d->selectedFilter >= 0 && d->selectedFilter < d->filterCount) { gtk_file_chooser_set_filter(GTK_FILE_CHOOSER(dialog), filters[d->selectedFilter]); From 90ee77db25cd77b45663afa23b0afde5623cec09 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:50:05 +0300 Subject: [PATCH 26/31] File dialogs: stage Android source saves, keep MIME family, iOS wildcard and coordinated copy, unchecked writer failures Co-Authored-By: Claude Opus 5.5 --- .../com/codename1/impl/FileDialogSupport.java | 5 +++ .../impl/android/AndroidFileDialogs.java | 45 +++++++++++++++++-- Ports/iOSPort/nativeSources/IOSNative.m | 35 ++++++++++++--- .../codename1/impl/ios/IOSImplementation.java | 12 +++++ 4 files changed, 89 insertions(+), 8 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/FileDialogSupport.java b/CodenameOne/src/com/codename1/impl/FileDialogSupport.java index 24264a1cf7a..7ce792895fc 100644 --- a/CodenameOne/src/com/codename1/impl/FileDialogSupport.java +++ b/CodenameOne/src/com/codename1/impl/FileDialogSupport.java @@ -231,6 +231,11 @@ && isSameFile(request.getSourceFile(), result.getPath())) { } catch (IOException err) { Log.e(err); result.setError(err); + } catch (RuntimeException err) { + // An unchecked failure in the application's writer still + // owes the caller its callback. + Log.e(err); + result.setError(err); } deliver(result, callback); } diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java b/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java index fedac5b67d8..7199d300d90 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java @@ -202,7 +202,9 @@ static void showSave(final SaveFileRequest request, final SuccessCallback= 0) { + // Only an unconstrained type falls back to the name: a family wildcard + // such as image/* is what the selected filter asked for. + if (type == null || "*/*".equals(type)) { String fromName = name == null ? null : mimeForExtension(extensionOf(name)); if (fromName != null) { type = fromName; @@ -238,17 +240,41 @@ public void onActivityResult(int requestCode, int resultCode, Intent data) { new Thread(new Runnable() { public void run() { OutputStream out = null; + File staged = null; try { + // The provider document may be the source file itself + // under another name; opening it truncates the source + // before it is read. A source-backed save is therefore + // copied to the cache first, as completeSave does. + if (request.getContent() == null && request.getSourceFile() != null) { + staged = File.createTempFile("cn1save", null, + AndroidNativeUtil.getContext().getCacheDir()); + OutputStream so = new FileOutputStream(staged); + try { + request.writeContent(result, so); + } finally { + so.close(); + } + } out = resolver.openOutputStream(uri); if (out == null) { throw new IOException("Cannot open " + uri); } - request.writeContent(result, out); + if (staged != null) { + copyStream(staged, out); + } else { + request.writeContent(result, out); + } } catch (IOException e) { result.setError(e); - } catch (SecurityException e) { + } catch (RuntimeException e) { + // SecurityException, or an unchecked failure in the + // application's writer; the callback is still owed. result.setError(e); } finally { + if (staged != null && !staged.delete()) { + staged.deleteOnExit(); + } if (out != null) { try { out.close(); @@ -603,6 +629,19 @@ private static void deleteRecursive(File f) { } } + private static void copyStream(File from, OutputStream out) throws IOException { + InputStream in = new java.io.FileInputStream(from); + try { + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) > -1) { + out.write(buffer, 0, read); + } + } finally { + in.close(); + } + } + private static void copyStream(ContentResolver resolver, Uri uri, File dest) throws IOException { InputStream in = resolver.openInputStream(uri); if (in == null) { diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 9754739f0c5..e5b013525fc 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -9231,15 +9231,40 @@ static void cn1FileDialogDeliver(NSString *paths, JAVA_INT requestId) { [fileManager createDirectoryAtPath:folder withIntermediateDirectories:YES attributes:nil error:nil]; NSString *destinationPath = [folder stringByAppendingPathComponent:fileName]; NSURL *destinationURL = [NSURL fileURLWithPath:destinationPath]; - NSError *error = nil; - if ([url isFileURL] && [fileManager copyItemAtURL:url toURL:destinationURL error:&error]) { + __block NSError *error = nil; + __block BOOL copied = NO; + if ([url isFileURL]) { + // A File Provider (iCloud Drive, a third-party provider) requires + // coordinated access to the item, folders above all; an + // uncoordinated copy can fail or read a half-updated tree. + NSFileCoordinator *coordinator = [[NSFileCoordinator alloc] initWithFilePresenter:nil]; + NSError *coordError = nil; + [coordinator coordinateReadingItemAtURL:url options:NSFileCoordinatorReadingWithoutChanges + error:&coordError byAccessor:^(NSURL *readURL) { + NSError *copyError = nil; + copied = [[NSFileManager defaultManager] copyItemAtURL:readURL toURL:destinationURL error:©Error]; + if (!copied) { + error = copyError; + } + }]; + if (coordError != nil && error == nil) { + error = coordError; + } +#if !__has_feature(objc_arc) + [coordinator release]; +#endif + } + if (copied) { result = destinationPath; } else { - NSData *data = [NSData dataWithContentsOfURL:url options:0 error:&error]; - if (data != nil && [data writeToURL:destinationURL options:NSDataWritingAtomic error:&error]) { + // A separate local: &error on a __block variable is not a valid + // ARC write-back target. + NSError *dataError = nil; + NSData *data = [NSData dataWithContentsOfURL:url options:0 error:&dataError]; + if (data != nil && [data writeToURL:destinationURL options:NSDataWritingAtomic error:&dataError]) { result = destinationPath; } else { - NSLog(@"Codename One file dialog failed to copy selected item: %@", error); + NSLog(@"Codename One file dialog failed to copy selected item: %@ %@", error, dataError); // Nothing is returned for this item, so its staging folder, // with whatever a partial folder copy left in it, goes now. [fileManager removeItemAtPath:folder error:nil]; diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 1bc0a43a3a0..2aae92f1603 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -6883,6 +6883,7 @@ public void showOpenDialog(com.codename1.io.filechooser.OpenFileRequest request, } java.util.ArrayList uts = new java.util.ArrayList(); java.util.ArrayList exts = new java.util.ArrayList(); + boolean allFiles = false; for (com.codename1.io.filechooser.FileTypeFilter f : request.getFilters()) { String[] u = f.getUtTypes(); if (u != null && u.length > 0) { @@ -6897,12 +6898,23 @@ public void showOpenDialog(com.codename1.io.filechooser.OpenFileRequest request, if (s != null && s.startsWith(".")) { s = s.substring(1); } + if ("*".equals(s)) { + allFiles = true; + continue; + } // Not case folded: UTType resolves extensions case // insensitively, and toLowerCase is locale sensitive. exts.add(s); } } } + if (allFiles) { + // "*" is the all-files extension; sent as a name it would become a + // dynamic UTI nothing conforms to. An empty type list makes the + // native picker fall back to public.item, i.e. every document. + uts.clear(); + exts.clear(); + } int id = registerFileDialog(new PendingFileDialog(request, callback, null)); nativeInstance.showOpenFileDialog(joinLines(uts), joinLines(exts), request.isMultiSelect(), request.isDirectories(), From f37784b340417f83d388fb6b2c166663f20a1952 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:32:15 +0300 Subject: [PATCH 27/31] File dialogs: unchecked writer failures on iOS and JS, Android launch failure, wildcard fallback accept Co-Authored-By: Claude Opus 5.5 --- .../io/filechooser/FileDialogRequest.java | 5 +++ .../impl/android/AndroidFileDialogs.java | 15 ++++++++- .../impl/html5/HTML5Implementation.java | 4 +++ .../codename1/impl/ios/IOSImplementation.java | 31 ++++++++++++------- 4 files changed, 43 insertions(+), 12 deletions(-) diff --git a/CodenameOne/src/com/codename1/io/filechooser/FileDialogRequest.java b/CodenameOne/src/com/codename1/io/filechooser/FileDialogRequest.java index 1355995ff9a..cd20bef897a 100644 --- a/CodenameOne/src/com/codename1/io/filechooser/FileDialogRequest.java +++ b/CodenameOne/src/com/codename1/io/filechooser/FileDialogRequest.java @@ -336,6 +336,11 @@ public String toAcceptString() { StringBuilder sb = new StringBuilder(); for (FileTypeFilter f : filters) { for (String e : f.getExtensions()) { + if ("*".equals(e)) { + // One filter takes every file, so the union of them all + // does too; any narrower token would hide files. + return null; + } append(sb, e); } for (String m : f.getMimeTypes()) { diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java b/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java index 7199d300d90..e9e467e12e5 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java @@ -345,7 +345,20 @@ public void run() { deliver(callback, r); return; } - AndroidNativeUtil.startActivityForResult(intent, REQUEST_CODE, listener); + try { + AndroidNativeUtil.startActivityForResult(intent, REQUEST_CODE, listener); + } catch (RuntimeException e) { + // No activity resolves the intent (ActivityNotFoundException) or the + // launch was refused. The listener is already installed and the + // activity marked busy, and no result will ever arrive to undo + // that, so the default channel is restored here. + if (activity instanceof CodenameOneActivity) { + ((CodenameOneActivity) activity).restoreIntentResultListener(); + } + FileDialogResult r = FileDialogResult.cancelled(request); + r.setError(e); + deliver(callback, r); + } } private static void applyInitialUri(FileDialogRequest request, Intent intent) { diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java index ed833b99f58..10f5b45b5c4 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java @@ -6669,6 +6669,10 @@ public void run() { } catch (IOException err) { Log.e(err); result.setError(err); + } catch (RuntimeException err) { + // an unchecked failure in the application's writer + Log.e(err); + result.setError(err); } catch (OutOfMemoryError err) { out = null; result.setError(new IOException("The document is too large to download from the browser")); diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 2aae92f1603..9a63bc8e825 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -6989,17 +6989,13 @@ public void run() { } finally { out.close(); } - } catch (final IOException err) { - deleteExportStaging(temp); - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - com.codename1.io.filechooser.FileDialogResult r = - com.codename1.io.filechooser.FileDialogResult.cancelled(request); - r.setError(err); - callback.onSucess(r); - } - }); + } catch (IOException err) { + failStaging(temp, err); + return; + } catch (RuntimeException err) { + // An unchecked failure in the application's writer: the + // staging still goes and the callback is still owed. + failStaging(temp, err); return; } int id = registerFileDialog(new PendingFileDialog(request, callback, temp)); @@ -7007,6 +7003,19 @@ public void run() { !request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.IOS_EXPORT_AS_MOVE, false), stripFileScheme(request.getInitialDirectory()), id); } + + private void failStaging(String temp, final Throwable err) { + deleteExportStaging(temp); + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + com.codename1.io.filechooser.FileDialogResult r = + com.codename1.io.filechooser.FileDialogResult.cancelled(request); + r.setError(err); + callback.onSucess(r); + } + }); + } }); } From 3967c0888ec767f215032e139350ceb6b30a2bb4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:13:36 +0300 Subject: [PATCH 28/31] File dialogs: strict Windows save types, empty filters unconstrain the accept union Co-Authored-By: Claude Opus 5.5 --- .../com/codename1/io/filechooser/FileDialogRequest.java | 4 ++++ .../com/codename1/impl/html5/HTML5Implementation.java | 5 +++++ .../WindowsPort/nativeSources/cn1_windows_filedialog.cpp | 8 ++++++++ .../src/com/codename1/impl/ios/IOSImplementation.java | 5 +++++ 4 files changed, 22 insertions(+) diff --git a/CodenameOne/src/com/codename1/io/filechooser/FileDialogRequest.java b/CodenameOne/src/com/codename1/io/filechooser/FileDialogRequest.java index cd20bef897a..b6ec4355b50 100644 --- a/CodenameOne/src/com/codename1/io/filechooser/FileDialogRequest.java +++ b/CodenameOne/src/com/codename1/io/filechooser/FileDialogRequest.java @@ -335,6 +335,10 @@ public Component getAccessory() { public String toAcceptString() { StringBuilder sb = new StringBuilder(); for (FileTypeFilter f : filters) { + if (f.getExtensions().length == 0 && f.getMimeTypes().length == 0) { + // an empty filter accepts every file, so the union does too + return null; + } for (String e : f.getExtensions()) { if ("*".equals(e)) { // One filter takes every file, so the union of them all diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java index 10f5b45b5c4..d600de14dd8 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java @@ -6603,6 +6603,11 @@ public void showOpenDialog(final com.codename1.io.filechooser.OpenFileRequest re StringBuilder accept = new StringBuilder(); boolean allFiles = false; for (com.codename1.io.filechooser.FileTypeFilter f : request.getFilters()) { + // A filter with no extensions and no MIME types accepts every file + // (FileTypeFilter.acceptsName), so it unconstrains the union too. + if (f.getExtensions().length == 0 && f.getMimeTypes().length == 0) { + allFiles = true; + } for (String e : f.getExtensions()) { // "*" is the all-files extension, but ".*" in accept names one // literal extension; a filter that takes everything means the diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_filedialog.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_filedialog.cpp index df8d9e1b3e8..ba17234d844 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_filedialog.cpp +++ b/Ports/WindowsPort/nativeSources/cn1_windows_filedialog.cpp @@ -371,6 +371,14 @@ void cn1FdxRun(Cn1FdxRequest* r) { opts |= FOS_FORCESHOWHIDDEN; } if (r->save) { + /* Without this the dialog accepts "report.txt" under a PDF filter and + * confirms overwriting report.txt, after which the Java side appends + * the filter's extension and writes report.txt.pdf unconfirmed. Strict + * types make the dialog itself settle the final name, so its overwrite + * prompt is about the file that is really written. */ + if (r->filterCount > 0) { + opts |= FOS_STRICTFILETYPES; + } if (r->confirmOverwrite) { opts |= FOS_OVERWRITEPROMPT; } else { diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 9a63bc8e825..af48b047cf7 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -6886,6 +6886,11 @@ public void showOpenDialog(com.codename1.io.filechooser.OpenFileRequest request, boolean allFiles = false; for (com.codename1.io.filechooser.FileTypeFilter f : request.getFilters()) { String[] u = f.getUtTypes(); + if ((u == null || u.length == 0) && f.getExtensions().length == 0 + && f.getMimeTypes().length == 0) { + // an unconstrained filter accepts every file, so the union does too + allFiles = true; + } if (u != null && u.length > 0) { for (String s : u) { uts.add(s); From d4b39269d8199ebef366306c2e040c7dad7b275e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:33:28 +0300 Subject: [PATCH 29/31] File dialogs: stage every save with rollback, convert only Windows separators Co-Authored-By: Claude Opus 5.5 --- .../com/codename1/impl/FileDialogSupport.java | 78 +++++++++++++------ .../codename1/impl/NativeFileDialogCodec.java | 13 +++- .../codename1/impl/FileDialogSupportTest.java | 65 +++++++++++++++- 3 files changed, 132 insertions(+), 24 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/FileDialogSupport.java b/CodenameOne/src/com/codename1/impl/FileDialogSupport.java index 7ce792895fc..68b87ac3441 100644 --- a/CodenameOne/src/com/codename1/impl/FileDialogSupport.java +++ b/CodenameOne/src/com/codename1/impl/FileDialogSupport.java @@ -218,16 +218,7 @@ && isSameFile(request.getSourceFile(), result.getPath())) { deliver(result, callback); return; } - if (request.getContent() == null) { - copyStaged(request.getSourceFile(), result.getPath()); - } else { - OutputStream out = FileSystemStorage.getInstance().openOutputStream(result.getPath()); - try { - request.writeContent(result, out); - } finally { - out.close(); - } - } + saveStaged(request, result); } catch (IOException err) { Log.e(err); result.setError(err); @@ -242,23 +233,31 @@ && isSameFile(request.getSourceFile(), result.getPath())) { }); } - /// Copies a save's source file to its destination through a staged copy. - /// The two can be the same file under different spellings (a drive path - /// against a `file:` URL, case on a case-insensitive volume, `..`, links), - /// which no textual comparison catches; opening the destination first would - /// then truncate the source before it was read. Reading the whole source into - /// a staging file before the destination is opened is safe for every alias. - private static void copyStaged(String source, String destination) throws IOException { - if (source == null) { - throw new IOException("SaveFileRequest has neither content nor a source file"); - } + /// Writes a save completely before the destination is touched, then + /// replaces the destination with rollback. + /// + /// Opening the destination first would truncate it before the content + /// exists: a writer that throws, or a full disk, would then destroy the + /// document being replaced. It would also truncate the source of a plain + /// copy whose destination is the same file under another spelling (a drive + /// path against a `file:` URL, case on a case-insensitive volume, `..`, + /// links), which no textual comparison catches. Staging covers both: the + /// content, or the whole source, is in the staging file before anything + /// opens the destination, and the old destination is kept aside until the + /// new one is fully written. + static void saveStaged(SaveFileRequest request, FileDialogResult result) throws IOException { FileSystemStorage fs = FileSystemStorage.getInstance(); String home = fs.getAppHomePath(); String staging = (home.endsWith("/") ? home : home + "/") + ".cn1-save-" + System.currentTimeMillis() + "-" + (stagingCounter++); try { - copy(fs, source, staging); - copy(fs, staging, destination); + OutputStream out = fs.openOutputStream(staging); + try { + request.writeContent(result, out); + } finally { + out.close(); + } + replace(fs, staging, result.getPath()); } finally { if (fs.exists(staging)) { fs.delete(staging); @@ -266,6 +265,41 @@ private static void copyStaged(String source, String destination) throws IOExcep } } + /// Copies the staged file over the destination. An existing destination is + /// renamed aside first and restored if the copy fails, so a failed save + /// leaves the original document as it was. + private static void replace(FileSystemStorage fs, String staging, String destination) throws IOException { + String backup = null; + String name = destination.substring(destination.lastIndexOf('/') + 1); + if (fs.exists(destination)) { + String backupName = "." + name + ".cn1-backup-" + System.currentTimeMillis(); + fs.rename(destination, backupName); + String candidate = destination.substring(0, destination.length() - name.length()) + backupName; + // A port whose rename cannot move it leaves the original in place; + // the copy below then overwrites it without a backup, which is + // still after the content was fully staged. + if (fs.exists(candidate) && !fs.exists(destination)) { + backup = candidate; + } + } + boolean written = false; + try { + copy(fs, staging, destination); + written = true; + } finally { + if (backup != null) { + if (written) { + fs.delete(backup); + } else { + if (fs.exists(destination)) { + fs.delete(destination); + } + fs.rename(backup, name); + } + } + } + } + // Only the single background-task thread touches this, through completeSave. private static int stagingCounter; diff --git a/CodenameOne/src/com/codename1/impl/NativeFileDialogCodec.java b/CodenameOne/src/com/codename1/impl/NativeFileDialogCodec.java index f207717127e..528a50ec574 100644 --- a/CodenameOne/src/com/codename1/impl/NativeFileDialogCodec.java +++ b/CodenameOne/src/com/codename1/impl/NativeFileDialogCodec.java @@ -168,7 +168,7 @@ public static FileDialogResult decode(FileDialogRequest request, String[] ans } String[] paths = new String[answer.length - first]; for (int i = 0; i < paths.length; i++) { - paths[i] = "file://" + answer[first + i].replace('\\', '/'); + paths[i] = "file://" + toUrlPath(answer[first + i]); } if (request instanceof com.codename1.io.filechooser.SaveFileRequest && filter >= 0) { FileTypeFilter f = request.getFilters()[filter]; @@ -177,6 +177,17 @@ public static FileDialogResult decode(FileDialogRequest request, String[] ans return new FileDialogResult(request, paths, filter, values); } + /// Converts a native path to the slash form a `file://` URL carries. + /// Only a Windows path (`C:\dir` or a `\\server\share` UNC path) has + /// backslash separators; on macOS and Linux a backslash is an ordinary + /// file name character, and turning it into `/` would name another file. + static String toUrlPath(String path) { + boolean windows = (path.length() > 2 && path.charAt(1) == ':' + && (path.charAt(2) == '\\' || path.charAt(2) == '/')) + || path.startsWith("\\\\"); + return windows ? path.replace('\\', '/') : path; + } + /// Delivers a result through `Display.callSerially()`, so the callback runs /// on the EDT even when the dialog was requested from another thread. /// diff --git a/maven/core-unittests/src/test/java/com/codename1/impl/FileDialogSupportTest.java b/maven/core-unittests/src/test/java/com/codename1/impl/FileDialogSupportTest.java index 87f09fe652d..353c3183333 100644 --- a/maven/core-unittests/src/test/java/com/codename1/impl/FileDialogSupportTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/impl/FileDialogSupportTest.java @@ -22,14 +22,24 @@ */ package com.codename1.impl; +import com.codename1.io.FileSystemStorage; +import com.codename1.io.filechooser.FileDialogResult; import com.codename1.io.filechooser.FileTypeFilter; +import com.codename1.io.filechooser.SaveFileRequest; +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; import org.junit.jupiter.api.Test; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -class FileDialogSupportTest { +class FileDialogSupportTest extends UITestBase { @Test void savingOntoTheSourceFileIsRecognized() { @@ -49,4 +59,57 @@ void withExtensionAppendsTheFiltersFirstExtensionOnlyWhenMissing() { assertEquals("r", FileDialogSupport.withExtension("r", null)); assertEquals("r", FileDialogSupport.withExtension("r", new FileTypeFilter("Any"))); } + + @Test + void onlyWindowsPathsHaveTheirBackslashesConverted() { + assertEquals("C:/Users/a/r.txt", NativeFileDialogCodec.toUrlPath("C:\\Users\\a\\r.txt")); + assertEquals("//server/share/r.txt", NativeFileDialogCodec.toUrlPath("\\\\server\\share\\r.txt")); + assertEquals("/tmp/a\\b.txt", NativeFileDialogCodec.toUrlPath("/tmp/a\\b.txt")); + } + + @FormTest + void aFailedWriterLeavesTheExistingDocumentIntact() throws IOException { + FileSystemStorage fs = FileSystemStorage.getInstance(); + String dest = fs.getAppHomePath() + "doc.txt"; + write(fs, dest, "original"); + SaveFileRequest request = new SaveFileRequest().content((result, out) -> { + out.write("partial".getBytes("UTF-8")); + throw new IOException("serialization failed"); + }); + FileDialogResult result = new FileDialogResult(request, new String[]{dest}, -1, null); + assertThrows(IOException.class, () -> FileDialogSupport.saveStaged(request, result)); + assertEquals("original", read(fs, dest)); + } + + @FormTest + void aSuccessfulSaveReplacesTheDocumentAndLeavesNoBackup() throws IOException { + FileSystemStorage fs = FileSystemStorage.getInstance(); + String home = fs.getAppHomePath(); + String dest = home + "doc2.txt"; + write(fs, dest, "original"); + SaveFileRequest request = new SaveFileRequest().content((result, out) -> out.write("new".getBytes("UTF-8"))); + FileDialogSupport.saveStaged(request, new FileDialogResult(request, new String[]{dest}, -1, null)); + assertEquals("new", read(fs, dest)); + for (String f : fs.listFiles(home)) { + assertFalse(f.contains("cn1-backup") || f.contains("cn1-save"), f); + } + } + + private static void write(FileSystemStorage fs, String path, String text) throws IOException { + OutputStream out = fs.openOutputStream(path); + try { + out.write(text.getBytes("UTF-8")); + } finally { + out.close(); + } + } + + private static String read(FileSystemStorage fs, String path) throws IOException { + InputStream in = fs.openInputStream(path); + try { + return new String(com.codename1.io.Util.readInputStream(in), "UTF-8"); + } finally { + in.close(); + } + } } From 4d82dcac1f9b0f36d450aea4898b393fcbd015ed Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:19:57 +0300 Subject: [PATCH 30/31] Baselines: take #6059's repair of #5976's merged overlays Co-Authored-By: Claude Opus 5.5 --- scripts/flutter-bench/baseline/pr/5976.json | 2 +- vm/selfhost/perf-baseline/pr/5976.json | 70 --------------------- 2 files changed, 1 insertion(+), 71 deletions(-) diff --git a/scripts/flutter-bench/baseline/pr/5976.json b/scripts/flutter-bench/baseline/pr/5976.json index 0cd73c825d2..5b507ff550f 100644 --- a/scripts/flutter-bench/baseline/pr/5976.json +++ b/scripts/flutter-bench/baseline/pr/5976.json @@ -8,7 +8,7 @@ "tolerance": { "value": 0.005 }, - "value": 13238731 + "value": 13304997 }, "reason": "JavaScript sizes on run 38943915580 (13,338,532 / 108,835,838 bytes) are above a baseline taken on the Flutter branch before later master merges; desktop-compat-layer's run 38946610873, which does not contain this PR, is further above it (13,356,239 / 108,853,545). The Flutter benchmark has no master run to separate this PR's share, which is ParparVM's JS output for float rounding (Math.fround) and the single-rounding long-to-float.", "runs": 1, diff --git a/vm/selfhost/perf-baseline/pr/5976.json b/vm/selfhost/perf-baseline/pr/5976.json index 02c3a50438f..4a238c017ad 100644 --- a/vm/selfhost/perf-baseline/pr/5976.json +++ b/vm/selfhost/perf-baseline/pr/5976.json @@ -42,24 +42,6 @@ "time": 0.5 } } - }, - "translator": { - "all": { - "from": { - "memory": 0.519, - "time": 0.626, - "tolerance": { - "time": 0.2 - } - }, - "memory": 0.519, - "reason": "Linux x64 EPYC 7763 translator time 0.49x on run 38943915665 (rounds 0.48-0.54); this PR's earlier runs on that model measured 0.51x and 0.55x against the 0.63x row", - "runs": 3, - "time": 0.508, - "tolerance": { - "time": 0.2 - } - } } }, "macos-arm64": { @@ -156,58 +138,6 @@ } } } - }, - "windows-arm64@armv8-64-bit-family-8-model-d49-microsoft-corporation": { - "hello": { - "all": { - "from": { - "memory": 1.277, - "time": 1.107, - "tolerance": { - "memory": 0.35 - } - }, - "memory": 0.773, - "reason": "First run after merging master with the Flutter runtime (ff8f935c20): hello RAM fell on macOS arm64 (1.17-1.47x before, 0.92x now) and on Windows arm64 d49 (1.28x to 0.77x) in the same run, so the move comes from master's changes, not this PR's", - "runs": 1, - "time": 1.107, - "tolerance": { - "memory": 0.35 - } - } - }, - "translator": { - "all": { - "from": { - "memory": 0.43, - "time": 0.838 - }, - "memory": 0.503, - "reason": "Windows arm64 d49 translator RAM 0.50x (rounds 0.48-0.55) is the 0.513x the row held before pr/5883.json moved it to 0.43x from one run", - "runs": 1, - "time": 0.838 - } - } - }, - "windows-x64@intel64-family-6-model-207-genuineintel": { - "recursion": { - "all": { - "from": { - "memory": 0.059, - "time": 0.945, - "tolerance": { - "time": 0.2 - } - }, - "memory": 0.059, - "reason": "Windows x64 Intel Family 6 Model 207 recursion time is bimodal: base folds moved it 1.204x -> 0.943x -> 1.212x -> 0.945x, each from one or two runs; this branch measured 1.207x across five rounds (1.197-1.217), the upper mode", - "runs": 1, - "time": 1.207, - "tolerance": { - "time": 0.2 - } - } - } } } } From 653527ddcb0b553dfc70fac72cd7ccaf01772457 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:03:49 +0300 Subject: [PATCH 31/31] File dialogs: hint keys become FileDialogRequest.HINT_ constants; the fallback open dialog picks several files FileDialogHints held nothing but the key names for FileDialogRequest.hint(), so the constants now live beside the method that takes them. The Codename One open dialog, used by ports without a native one, answered a multi-select request with a single file. It now shows a file tree whose files toggle a check mark and returns every checked file in the order it was checked, and the fallback reports FileDialogFeatures.MULTI_SELECT. Co-Authored-By: Claude Opus 5.5 --- .../impl/CodenameOneImplementation.java | 33 ++++--- .../com/codename1/impl/FileDialogSupport.java | 41 ++++++++- .../codename1/impl/MultiSelectFileTree.java | 90 +++++++++++++++++++ .../io/filechooser/FileDialogHints.java | 62 ------------- .../io/filechooser/FileDialogRequest.java | 34 ++++++- .../impl/android/AndroidFileDialogs.java | 9 +- .../impl/javase/JavaSEFileDialogs.java | 4 +- .../impl/linux/LinuxImplementation.java | 4 +- .../impl/windows/WindowsImplementation.java | 12 +-- .../codename1/impl/ios/IOSImplementation.java | 14 +-- .../snippets/generated/IoJava178Snippet.java | 4 +- docs/developer-guide/io.asciidoc | 7 +- .../codename1/impl/FileDialogSupportTest.java | 23 +++++ .../io/filechooser/FileDialogRequestTest.java | 8 +- 14 files changed, 240 insertions(+), 105 deletions(-) create mode 100644 CodenameOne/src/com/codename1/impl/MultiSelectFileTree.java delete mode 100644 CodenameOne/src/com/codename1/io/filechooser/FileDialogHints.java diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index fd30a93104c..d546b46a245 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -8896,6 +8896,19 @@ public void openGallery(final ActionListener response, int type) { public void openFileChooser(final ActionListener response, String accept) { final Dialog d = new Dialog("Select a file"); d.setLayout(new BorderLayout()); + FileTree t = new OpenGalleryFileTree(fileTreeModel(accept), response, d); + d.addComponent(BorderLayout.CENTER, t); + d.placeButtonCommands(new Command[]{new Command("Cancel")}); + Command c = d.showAtPosition(2, 2, 2, 2, true); + if (c != null) { + response.actionPerformed(null); + } + } + + /// The file system model behind the fallback open dialogs, narrowed to the + /// extensions in an `openFileChooser` accept string. MIME types and the `*` + /// wildcard do not narrow it. + static FileTreeModel fileTreeModel(String accept) { FileTreeModel model = new FileTreeModel(true); if (accept != null) { Vector tokens = StringUtil.tokenizeString(accept, ','); @@ -8910,19 +8923,14 @@ public void openFileChooser(final ActionListener response, String accept) { } } } - - FileTree t = new OpenGalleryFileTree(model, response, d); - d.addComponent(BorderLayout.CENTER, t); - d.placeButtonCommands(new Command[]{new Command("Cancel")}); - Command c = d.showAtPosition(2, 2, 2, 2, true); - if (c != null) { - response.actionPerformed(null); - } + return model; } - /// Shows an Open dialog. The default answers through `#openFileChooser`, so - /// a port with a native single-file picker already gets a native dialog; - /// ports override this to support the rest of the request. + /// Shows an Open dialog. The default answers a single-file request through + /// `#openFileChooser`, so a port with a native single-file picker already gets + /// a native dialog, and a multi-select request with a Codename One file tree + /// whose files toggle a check mark. Ports override this to support the rest + /// of the request. /// /// #### Parameters /// @@ -8972,7 +8980,8 @@ public boolean isFileDialogFeatureSupported(int feature) { // The fallback save form (FileDialogSupport.showSave) renders the filter // list, every portable option and the accessory, so those are reported; // FileDialogFeatures documents that the fallback open dialog does not. - return feature == FileDialogFeatures.SAVE_NAME_EDITING + return feature == FileDialogFeatures.MULTI_SELECT + || feature == FileDialogFeatures.SAVE_NAME_EDITING || feature == FileDialogFeatures.FILTER_LIST || feature == FileDialogFeatures.OPTIONS || feature == FileDialogFeatures.ACCESSORY_COMPONENT; diff --git a/CodenameOne/src/com/codename1/impl/FileDialogSupport.java b/CodenameOne/src/com/codename1/impl/FileDialogSupport.java index 68b87ac3441..2d84134ff5e 100644 --- a/CodenameOne/src/com/codename1/impl/FileDialogSupport.java +++ b/CodenameOne/src/com/codename1/impl/FileDialogSupport.java @@ -34,6 +34,7 @@ import com.codename1.ui.Button; import com.codename1.ui.CheckBox; import com.codename1.ui.ComboBox; +import com.codename1.ui.Command; import com.codename1.ui.Container; import com.codename1.ui.Dialog; import com.codename1.ui.Display; @@ -60,8 +61,12 @@ private FileDialogSupport() { /// Answers an open request through `CodenameOneImplementation#openFileChooser`, /// which every port already implements, natively where it can. - static void showOpen(CodenameOneImplementation impl, final OpenFileRequest request, + static void showOpen(final CodenameOneImplementation impl, final OpenFileRequest request, final SuccessCallback callback) { + if (request.isMultiSelect() && !request.isDirectories()) { + showMultiOpen(request, callback); + return; + } impl.openFileChooser(new ActionListener() { @Override public void actionPerformed(ActionEvent evt) { @@ -76,6 +81,40 @@ public void actionPerformed(ActionEvent evt) { }, request.toAcceptString()); } + /// The fallback Open dialog for several files: a file tree whose files toggle + /// a check mark, answered by Open with every checked file in the order it was + /// checked. Open with nothing checked is a cancellation, as on the native pickers. + static void showMultiOpen(final OpenFileRequest request, final SuccessCallback callback) { + if (!Display.getInstance().isEdt()) { + // The dialog is modal and must be built on the EDT. + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + showMultiOpen(request, callback); + } + }); + return; + } + Dialog d = new Dialog(request.getTitle() == null ? "Select files" : request.getTitle()); + d.setLayout(new BorderLayout()); + if (request.getMessage() != null) { + d.add(BorderLayout.NORTH, new Label(request.getMessage())); + } + MultiSelectFileTree tree = new MultiSelectFileTree( + CodenameOneImplementation.fileTreeModel(request.toAcceptString())); + d.add(BorderLayout.CENTER, tree); + Command cancel = new Command("Cancel"); + Command open = new Command(request.getAcceptLabel() == null ? "Open" : request.getAcceptLabel()); + d.placeButtonCommands(new Command[]{cancel, open}); + Command picked = d.showAtPosition(2, 2, 2, 2, true); + String[] paths = tree.getSelectedPaths(); + if (picked == open && paths.length > 0) { + deliver(new FileDialogResult(request, paths, -1, null), callback); + } else { + deliver(FileDialogResult.cancelled(request), callback); + } + } + /// Shows the fallback save form: a name field, the filter list and the /// request's options, saving into its initial directory or app home. static void showSave(final CodenameOneImplementation impl, final SaveFileRequest request, diff --git a/CodenameOne/src/com/codename1/impl/MultiSelectFileTree.java b/CodenameOne/src/com/codename1/impl/MultiSelectFileTree.java new file mode 100644 index 00000000000..fcea65fd497 --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/MultiSelectFileTree.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.impl; + +import com.codename1.components.FileTree; +import com.codename1.components.FileTreeModel; +import com.codename1.ui.Button; +import com.codename1.ui.FontImage; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; + +import java.util.ArrayList; +import java.util.List; + +/// The file tree of the fallback Open dialog when several files may be picked: +/// a leaf toggles a check mark instead of closing the dialog. +class MultiSelectFileTree extends FileTree { + // Insertion ordered, so the result lists files in the order they were picked. + // Not initialized here: Tree's constructor builds the first branch, calling + // createNodeComponent before this class's field initializers have run. + private List selected; + + MultiSelectFileTree(FileTreeModel model) { + super(model); + } + + /// The checked paths, in the order they were checked. + String[] getSelectedPaths() { + List selected = selection(); + return selected.toArray(new String[selected.size()]); + } + + @Override + protected Button createNodeComponent(final Object node, int depth) { + final Button b = super.createNodeComponent(node, depth); + if (!(node instanceof String) || !getModel().isLeaf(node)) { + return b; + } + final String path = (String) node; + final List selected = selection(); + // A collapsed folder drops its leaves and builds them again when it is + // reopened, so the mark is read back from the selection, not kept on the button. + mark(b, selected.contains(path)); + b.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + boolean check = !selected.contains(path); + if (check) { + selected.add(path); + } else { + selected.remove(path); + } + mark(b, check); + } + }); + return b; + } + + private List selection() { + if (selected == null) { + selected = new ArrayList(); + } + return selected; + } + + private void mark(Button b, boolean checked) { + setNodeMaterialIcon(checked ? FontImage.MATERIAL_CHECK_BOX : FontImage.MATERIAL_CHECK_BOX_OUTLINE_BLANK, b, 3); + } +} diff --git a/CodenameOne/src/com/codename1/io/filechooser/FileDialogHints.java b/CodenameOne/src/com/codename1/io/filechooser/FileDialogHints.java deleted file mode 100644 index f1b85e8c1fe..00000000000 --- a/CodenameOne/src/com/codename1/io/filechooser/FileDialogHints.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. Codename One designates this - * particular file as subject to the "Classpath" exception as provided - * by Oracle in the LICENSE file that accompanied this code. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * version 2 for more details (a copy is included in the LICENSE file that - * accompanied this code). - * - * You should have received a copy of the GNU General Public License version - * 2 along with this work; if not, write to the Free Software Foundation, - * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. - * - * Please contact Codename One through http://www.codenameone.com/ if you - * need additional information or have any questions. - */ - -package com.codename1.io.filechooser; - -/// Platform specific hints for `FileDialogRequest#hint(String, Object)`. -/// -/// A hint tunes a native dialog in a way only some platforms can. A platform -/// that does not know a hint ignores it, so an application can set every hint -/// it cares about unconditionally. The prefix names the platform that reads it. -public final class FileDialogHints { - /// `Boolean`: show the Finder tag field in a macOS save panel. - public static final String MAC_SHOWS_TAG_FIELD = "mac.showsTagField"; - /// `Boolean`: allow creating folders from a macOS panel. Defaults to true. - public static final String MAC_CAN_CREATE_DIRECTORIES = "mac.canCreateDirectories"; - /// `Boolean`: show hidden files in a macOS panel. - public static final String MAC_SHOWS_HIDDEN_FILES = "mac.showsHiddenFiles"; - /// `Boolean`: let the user hide the extension in a macOS save panel. - public static final String MAC_CAN_SELECT_HIDDEN_EXTENSION = "mac.canSelectHiddenExtension"; - /// `Boolean`: treat packages (`.app`, `.bundle`) as folders in a macOS open panel. - public static final String MAC_TREATS_PACKAGES_AS_DIRECTORIES = "mac.treatsFilePackagesAsDirectories"; - /// `Boolean`: show file extensions in the iOS document picker. - public static final String IOS_SHOWS_FILE_EXTENSIONS = "ios.showsFileExtensions"; - /// `Boolean`: move rather than copy the exported file on iOS. Defaults to copy. - public static final String IOS_EXPORT_AS_MOVE = "ios.exportAsMove"; - /// `String`: a `content://` URI the Android picker starts in. - public static final String ANDROID_INITIAL_URI = "android.initialUri"; - /// `Boolean`: keep persistable read/write permission to the picked - /// Android document across restarts. - public static final String ANDROID_PERSIST_PERMISSION = "android.persistPermission"; - /// `String`: a GUID string; Windows remembers the last folder and size - /// separately per GUID, so different dialogs of one app do not share state. - public static final String WIN_CLIENT_GUID = "win.clientGuid"; - /// `String`: the folder Windows shows when the user has no recent folder, - /// as opposed to `FileDialogRequest#initialDirectory`, which always wins. - public static final String WIN_DEFAULT_FOLDER = "win.defaultFolder"; - /// `Boolean`: show hidden files in the GTK chooser and the simulator. - public static final String DESKTOP_SHOW_HIDDEN = "desktop.showHidden"; - - private FileDialogHints() { - } -} diff --git a/CodenameOne/src/com/codename1/io/filechooser/FileDialogRequest.java b/CodenameOne/src/com/codename1/io/filechooser/FileDialogRequest.java index b6ec4355b50..ffa526bf86d 100644 --- a/CodenameOne/src/com/codename1/io/filechooser/FileDialogRequest.java +++ b/CodenameOne/src/com/codename1/io/filechooser/FileDialogRequest.java @@ -41,6 +41,38 @@ /// /// - ``: the concrete request type, so chained setters keep it public abstract class FileDialogRequest> { + // Platform specific hints for #hint(String, Object). A platform that does + // not know a hint ignores it, so an application can set every hint it cares + // about unconditionally. The prefix after HINT_ names the platform that reads it. + + /// `Boolean`: show the Finder tag field in a macOS save panel. + public static final String HINT_MAC_SHOWS_TAG_FIELD = "mac.showsTagField"; + /// `Boolean`: allow creating folders from a macOS panel. Defaults to true. + public static final String HINT_MAC_CAN_CREATE_DIRECTORIES = "mac.canCreateDirectories"; + /// `Boolean`: show hidden files in a macOS panel. + public static final String HINT_MAC_SHOWS_HIDDEN_FILES = "mac.showsHiddenFiles"; + /// `Boolean`: let the user hide the extension in a macOS save panel. + public static final String HINT_MAC_CAN_SELECT_HIDDEN_EXTENSION = "mac.canSelectHiddenExtension"; + /// `Boolean`: treat packages (`.app`, `.bundle`) as folders in a macOS open panel. + public static final String HINT_MAC_TREATS_PACKAGES_AS_DIRECTORIES = "mac.treatsFilePackagesAsDirectories"; + /// `Boolean`: show file extensions in the iOS document picker. + public static final String HINT_IOS_SHOWS_FILE_EXTENSIONS = "ios.showsFileExtensions"; + /// `Boolean`: move rather than copy the exported file on iOS. Defaults to copy. + public static final String HINT_IOS_EXPORT_AS_MOVE = "ios.exportAsMove"; + /// `String`: a `content://` URI the Android picker starts in. + public static final String HINT_ANDROID_INITIAL_URI = "android.initialUri"; + /// `Boolean`: keep persistable read/write permission to the picked + /// Android document across restarts. + public static final String HINT_ANDROID_PERSIST_PERMISSION = "android.persistPermission"; + /// `String`: a GUID string; Windows remembers the last folder and size + /// separately per GUID, so different dialogs of one app do not share state. + public static final String HINT_WIN_CLIENT_GUID = "win.clientGuid"; + /// `String`: the folder Windows shows when the user has no recent folder, + /// as opposed to `FileDialogRequest#initialDirectory`, which always wins. + public static final String HINT_WIN_DEFAULT_FOLDER = "win.defaultFolder"; + /// `Boolean`: show hidden files in the GTK chooser and the simulator. + public static final String HINT_DESKTOP_SHOW_HIDDEN = "desktop.showHidden"; + private String title; private String message; private String acceptLabel; @@ -145,7 +177,7 @@ public T selectedFilter(int index) { return self(); } - /// Sets a platform specific hint; see `FileDialogHints`. Unknown hints are + /// Sets a platform specific hint; see the `HINT_` constants. Unknown hints are /// ignored. /// /// #### Parameters diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java b/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java index e9e467e12e5..a415f88050f 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidFileDialogs.java @@ -32,7 +32,6 @@ import android.provider.OpenableColumns; import android.webkit.MimeTypeMap; import com.codename1.io.filechooser.FileDialogFeatures; -import com.codename1.io.filechooser.FileDialogHints; import com.codename1.io.filechooser.FileDialogRequest; import com.codename1.io.filechooser.FileDialogResult; import com.codename1.io.filechooser.FileTypeFilter; @@ -73,7 +72,7 @@ static boolean isFeatureSupported(int feature) { case FileDialogFeatures.INITIAL_DIRECTORY: // The portable initialDirectory(path) is ignored: SAF starts from a // document URI and a file path has no general mapping to one. The - // FileDialogHints.ANDROID_INITIAL_URI hint (API 26+) is the Android + // FileDialogRequest.HINT_ANDROID_INITIAL_URI hint (API 26+) is the Android // equivalent, and a hint is not what this feature reports. return false; default: @@ -104,7 +103,7 @@ static void showOpen(final OpenFileRequest request, final SuccessCallback request, Intent intent) { - String initial = request.getStringHint(FileDialogHints.ANDROID_INITIAL_URI); + String initial = request.getStringHint(FileDialogRequest.HINT_ANDROID_INITIAL_URI); if (initial != null && initial.length() > 0 && Build.VERSION.SDK_INT >= 26) { intent.putExtra(android.provider.DocumentsContract.EXTRA_INITIAL_URI, Uri.parse(initial)); } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEFileDialogs.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEFileDialogs.java index 56e4fb9f600..258e7222691 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEFileDialogs.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEFileDialogs.java @@ -110,7 +110,7 @@ public void run() { private static boolean needsSwing(FileDialogRequest request) { if (request.getFilters().length > 1 || request.getOptions().length > 0 - || request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.DESKTOP_SHOW_HIDDEN, false)) { + || request.getBooleanHint(com.codename1.io.filechooser.FileDialogRequest.HINT_DESKTOP_SHOW_HIDDEN, false)) { return true; } if (request.getAcceptLabel() != null) { @@ -245,7 +245,7 @@ private static JFileChooser createChooser(FileDialogRequest request, JCompone chooser.setCurrentDirectory(dir); } chooser.setFileHidingEnabled(!request.getBooleanHint( - com.codename1.io.filechooser.FileDialogHints.DESKTOP_SHOW_HIDDEN, false)); + com.codename1.io.filechooser.FileDialogRequest.HINT_DESKTOP_SHOW_HIDDEN, false)); if (request.getAcceptLabel() != null) { chooser.setApproveButtonText(request.getAcceptLabel()); } diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index 9afe4c51215..13dbf2971bf 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -2621,7 +2621,7 @@ public void showOpenDialog(com.codename1.io.filechooser.OpenFileRequest request, com.codename1.impl.NativeFileDialogCodec.toNativePath(request.getInitialDirectory()), null, com.codename1.impl.NativeFileDialogCodec.encodeFilters(request), request.getSelectedFilter(), request.isMultiSelect(), request.isDirectories(), false, - request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.DESKTOP_SHOW_HIDDEN, false), + request.getBooleanHint(com.codename1.io.filechooser.FileDialogRequest.HINT_DESKTOP_SHOW_HIDDEN, false), com.codename1.impl.NativeFileDialogCodec.encodeOptions(request)); com.codename1.impl.NativeFileDialogCodec.deliver(callback, com.codename1.impl.NativeFileDialogCodec.decode(request, answer)); } @@ -2637,7 +2637,7 @@ public void showSaveDialog(com.codename1.io.filechooser.SaveFileRequest request, request.getSuggestedName(), com.codename1.impl.NativeFileDialogCodec.encodeFilters(request), request.getSelectedFilter(), false, false, request.isConfirmOverwrite(), - request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.DESKTOP_SHOW_HIDDEN, false), + request.getBooleanHint(com.codename1.io.filechooser.FileDialogRequest.HINT_DESKTOP_SHOW_HIDDEN, false), com.codename1.impl.NativeFileDialogCodec.encodeOptions(request)); com.codename1.io.filechooser.FileDialogResult result = com.codename1.impl.NativeFileDialogCodec.decode(request, answer); diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index 4f8d5efdca4..82e3045b689 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -2638,10 +2638,10 @@ public void showOpenDialog(com.codename1.io.filechooser.OpenFileRequest request, com.codename1.impl.NativeFileDialogCodec.toNativePath(request.getInitialDirectory()), null, com.codename1.impl.NativeFileDialogCodec.encodeFilters(request), request.getSelectedFilter(), request.isMultiSelect(), request.isDirectories(), false, - request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.DESKTOP_SHOW_HIDDEN, false), + request.getBooleanHint(com.codename1.io.filechooser.FileDialogRequest.HINT_DESKTOP_SHOW_HIDDEN, false), com.codename1.impl.NativeFileDialogCodec.encodeOptions(request), - request.getStringHint(com.codename1.io.filechooser.FileDialogHints.WIN_CLIENT_GUID), - com.codename1.impl.NativeFileDialogCodec.toNativePath(request.getStringHint(com.codename1.io.filechooser.FileDialogHints.WIN_DEFAULT_FOLDER))); + request.getStringHint(com.codename1.io.filechooser.FileDialogRequest.HINT_WIN_CLIENT_GUID), + com.codename1.impl.NativeFileDialogCodec.toNativePath(request.getStringHint(com.codename1.io.filechooser.FileDialogRequest.HINT_WIN_DEFAULT_FOLDER))); com.codename1.impl.NativeFileDialogCodec.deliver(callback, com.codename1.impl.NativeFileDialogCodec.decode(request, answer)); } @@ -2653,10 +2653,10 @@ public void showSaveDialog(com.codename1.io.filechooser.SaveFileRequest request, request.getSuggestedName(), com.codename1.impl.NativeFileDialogCodec.encodeFilters(request), request.getSelectedFilter(), false, false, request.isConfirmOverwrite(), - request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.DESKTOP_SHOW_HIDDEN, false), + request.getBooleanHint(com.codename1.io.filechooser.FileDialogRequest.HINT_DESKTOP_SHOW_HIDDEN, false), com.codename1.impl.NativeFileDialogCodec.encodeOptions(request), - request.getStringHint(com.codename1.io.filechooser.FileDialogHints.WIN_CLIENT_GUID), - com.codename1.impl.NativeFileDialogCodec.toNativePath(request.getStringHint(com.codename1.io.filechooser.FileDialogHints.WIN_DEFAULT_FOLDER))); + request.getStringHint(com.codename1.io.filechooser.FileDialogRequest.HINT_WIN_CLIENT_GUID), + com.codename1.impl.NativeFileDialogCodec.toNativePath(request.getStringHint(com.codename1.io.filechooser.FileDialogRequest.HINT_WIN_DEFAULT_FOLDER))); com.codename1.io.filechooser.FileDialogResult result = com.codename1.impl.NativeFileDialogCodec.decode(request, answer); if (result.isCancelled()) { com.codename1.impl.NativeFileDialogCodec.deliver(callback, result); diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index c3b69a2a466..fbef6968cba 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -6904,7 +6904,7 @@ public void showOpenDialog(com.codename1.io.filechooser.OpenFileRequest request, nativeInstance.showOpenFileDialog(joinLines(uts), joinLines(exts), request.isMultiSelect(), request.isDirectories(), stripFileScheme(request.getInitialDirectory()), - request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.IOS_SHOWS_FILE_EXTENSIONS, false), + request.getBooleanHint(com.codename1.io.filechooser.FileDialogRequest.HINT_IOS_SHOWS_FILE_EXTENSIONS, false), id); } @@ -6985,7 +6985,7 @@ public void run() { } int id = registerFileDialog(new PendingFileDialog(request, callback, temp)); nativeInstance.showSaveFileDialog(temp, - !request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.IOS_EXPORT_AS_MOVE, false), + !request.getBooleanHint(com.codename1.io.filechooser.FileDialogRequest.HINT_IOS_EXPORT_AS_MOVE, false), stripFileScheme(request.getInitialDirectory()), id); } @@ -7033,11 +7033,11 @@ private void showMacDialog(com.codename1.io.filechooser.FileDialogRequest req request.getSelectedFilter(), joinRecords(com.codename1.impl.NativeFileDialogCodec.encodeOptions(request)), multi, dirs, - request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.MAC_SHOWS_TAG_FIELD, false), - request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.MAC_CAN_CREATE_DIRECTORIES, true), - request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.MAC_SHOWS_HIDDEN_FILES, false), - request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.MAC_CAN_SELECT_HIDDEN_EXTENSION, false), - request.getBooleanHint(com.codename1.io.filechooser.FileDialogHints.MAC_TREATS_PACKAGES_AS_DIRECTORIES, false), + request.getBooleanHint(com.codename1.io.filechooser.FileDialogRequest.HINT_MAC_SHOWS_TAG_FIELD, false), + request.getBooleanHint(com.codename1.io.filechooser.FileDialogRequest.HINT_MAC_CAN_CREATE_DIRECTORIES, true), + request.getBooleanHint(com.codename1.io.filechooser.FileDialogRequest.HINT_MAC_SHOWS_HIDDEN_FILES, false), + request.getBooleanHint(com.codename1.io.filechooser.FileDialogRequest.HINT_MAC_CAN_SELECT_HIDDEN_EXTENSION, false), + request.getBooleanHint(com.codename1.io.filechooser.FileDialogRequest.HINT_MAC_TREATS_PACKAGES_AS_DIRECTORIES, false), id); } diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/IoJava178Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/IoJava178Snippet.java index 6127723a59d..d5bd165adca 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/IoJava178Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/IoJava178Snippet.java @@ -91,8 +91,8 @@ void sample() { .extensions("txt").mimeTypes("text/plain")) .addOption(FileDialogOption.choice("pages", "Pages", "All", "Current")) .accessory(formatPanel) - .hint(FileDialogHints.MAC_SHOWS_TAG_FIELD, true) - .hint(FileDialogHints.WIN_CLIENT_GUID, "6f1c2c7e-9a1b-4c55-8f2a-3e5d1b0a7c11") + .hint(FileDialogRequest.HINT_MAC_SHOWS_TAG_FIELD, true) + .hint(FileDialogRequest.HINT_WIN_CLIENT_GUID, "6f1c2c7e-9a1b-4c55-8f2a-3e5d1b0a7c11") .content((result, out) -> { boolean pdf = result.getSelectedFilterIndex() == 0; boolean all = "All".equals(result.getOptionValue("pages")); diff --git a/docs/developer-guide/io.asciidoc b/docs/developer-guide/io.asciidoc index 73feb830f21..dddcc713f9e 100644 --- a/docs/developer-guide/io.asciidoc +++ b/docs/developer-guide/io.asciidoc @@ -157,7 +157,7 @@ Every platform can open and save a single file. Some features apply only where t * **Filters** (`FileTypeFilter`) carry extensions, MIME types and Apple type identifiers, and each platform reads the form it understands. Extensions alone work everywhere. * **Options** (`FileDialogOption`) are declarative checkboxes, choice lists and text fields. Each desktop maps them to its own controls. Where a platform shows none, `FileDialogResult.getOptionValue()` returns the default value. * **Accessory** components are Codename One components embedded in the dialog. Today only the Codename One fallback save form shows one, so prefer options. -* **Hints** (`FileDialogHints`) are platform specific settings, such as the macOS tag field, a Windows client GUID or the folder the browser starts in. A platform that doesn't recognize a hint ignores it. +* **Hints** (`FileDialogRequest.hint()` with a `FileDialogRequest.HINT_` constant) are platform specific settings, such as the macOS tag field, a Windows client GUID or the folder Android starts in. A platform that doesn't recognize a hint ignores it. Call `Display.isFileDialogFeatureSupported()` with a `FileDialogFeatures` constant to check a feature at runtime: @@ -205,6 +205,11 @@ include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/g |The browser file picker, several files |A download with the suggested name and extension |None + +|Other ports +|A Codename One file tree. With `multiSelect(true)` each file toggles a check mark and *Open* returns every checked file. +|A Codename One form with a name field, saved into the starting folder or the app home +|Filter list, options and accessory on the save form only |=== A more advanced usage of the `FileSystemStorage` API can be a `FileSystemStorage` `Tree`: diff --git a/maven/core-unittests/src/test/java/com/codename1/impl/FileDialogSupportTest.java b/maven/core-unittests/src/test/java/com/codename1/impl/FileDialogSupportTest.java index 353c3183333..42d08ecda9e 100644 --- a/maven/core-unittests/src/test/java/com/codename1/impl/FileDialogSupportTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/impl/FileDialogSupportTest.java @@ -22,18 +22,21 @@ */ package com.codename1.impl; +import com.codename1.components.FileTreeModel; import com.codename1.io.FileSystemStorage; import com.codename1.io.filechooser.FileDialogResult; import com.codename1.io.filechooser.FileTypeFilter; import com.codename1.io.filechooser.SaveFileRequest; import com.codename1.junit.FormTest; import com.codename1.junit.UITestBase; +import com.codename1.ui.Button; import org.junit.jupiter.api.Test; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -95,6 +98,26 @@ void aSuccessfulSaveReplacesTheDocumentAndLeavesNoBackup() throws IOException { } } + @FormTest + void theMultiSelectTreeReturnsCheckedFilesInTheOrderTheyWereChecked() throws IOException { + FileSystemStorage fs = FileSystemStorage.getInstance(); + String a = fs.getAppHomePath() + "a.txt"; + String b = fs.getAppHomePath() + "b.txt"; + String c = fs.getAppHomePath() + "c.txt"; + write(fs, a, "a"); + write(fs, b, "b"); + write(fs, c, "c"); + MultiSelectFileTree tree = new MultiSelectFileTree(new FileTreeModel(true)); + Button na = tree.createNodeComponent(a, 0); + Button nb = tree.createNodeComponent(b, 0); + Button nc = tree.createNodeComponent(c, 0); + nc.released(); + na.released(); + nb.released(); + nb.released(); + assertArrayEquals(new String[]{c, a}, tree.getSelectedPaths()); + } + private static void write(FileSystemStorage fs, String path, String text) throws IOException { OutputStream out = fs.openOutputStream(path); try { diff --git a/maven/core-unittests/src/test/java/com/codename1/io/filechooser/FileDialogRequestTest.java b/maven/core-unittests/src/test/java/com/codename1/io/filechooser/FileDialogRequestTest.java index 62c54ce7525..56b157c67e8 100644 --- a/maven/core-unittests/src/test/java/com/codename1/io/filechooser/FileDialogRequestTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/io/filechooser/FileDialogRequestTest.java @@ -67,13 +67,13 @@ void selectedFilterIsClamped() { @Test void hintsAreTyped() { OpenFileRequest r = new OpenFileRequest() - .hint(FileDialogHints.MAC_SHOWS_HIDDEN_FILES, Boolean.TRUE) - .hint(FileDialogHints.WIN_DEFAULT_FOLDER, "documents") + .hint(FileDialogRequest.HINT_MAC_SHOWS_HIDDEN_FILES, Boolean.TRUE) + .hint(FileDialogRequest.HINT_WIN_DEFAULT_FOLDER, "documents") .hint("x.flag", "TRUE"); - assertTrue(r.getBooleanHint(FileDialogHints.MAC_SHOWS_HIDDEN_FILES, false)); + assertTrue(r.getBooleanHint(FileDialogRequest.HINT_MAC_SHOWS_HIDDEN_FILES, false)); assertTrue(r.getBooleanHint("x.flag", false)); assertTrue(r.getBooleanHint("missing", true)); - assertEquals("documents", r.getStringHint(FileDialogHints.WIN_DEFAULT_FOLDER)); + assertEquals("documents", r.getStringHint(FileDialogRequest.HINT_WIN_DEFAULT_FOLDER)); assertNull(r.getStringHint("missing")); }