From 93dc70b10ffb8fdd734d564ca6339d704784bcc3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:30:27 +0200 Subject: [PATCH 1/3] Android compat: document intents via native file dialogs; iOS background guide ACTION_OPEN_DOCUMENT, ACTION_CREATE_DOCUMENT and non-media GET_CONTENT threw ActivityNotFoundException. They now open Display.showOpenDialog / showSaveDialog and return through onActivityResult and the AndroidX contracts (OpenDocument, OpenMultipleDocuments, CreateDocument, GetMultipleContents). Adds ClipData, persistable-permission no-ops and openOutputStream(uri, mode). The developer guide gains a section mapping Android foreground services, background location and WorkManager to iOS behaviour. Co-Authored-By: Claude Opus 5.5 --- docs/developer-guide/Android-Interop.asciidoc | 89 +++++++ .../main/java/android/app/ActivityThread.java | 24 ++ .../main/java/android/content/ClipData.java | 91 +++++++ .../java/android/content/ContentResolver.java | 27 ++- .../src/main/java/android/content/Intent.java | 14 ++ .../androidx/activity/ComponentActivity.java | 3 +- .../contract/ActivityResultContracts.java | 88 +++++++ .../androidcompat/runtime/AndroidRuntime.java | 2 +- .../runtime/DocumentIntents.java | 227 ++++++++++++++++++ .../activity/DocumentIntentsTest.java | 189 +++++++++++++++ .../testing/HeadlessImplementation.java | 25 ++ 11 files changed, 776 insertions(+), 3 deletions(-) create mode 100644 maven/android-compat/src/main/java/android/content/ClipData.java create mode 100644 maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/DocumentIntents.java create mode 100644 maven/android-compat/src/test/java/androidx/activity/DocumentIntentsTest.java diff --git a/docs/developer-guide/Android-Interop.asciidoc b/docs/developer-guide/Android-Interop.asciidoc index 4174ed3461e..168d3bfbc8d 100644 --- a/docs/developer-guide/Android-Interop.asciidoc +++ b/docs/developer-guide/Android-Interop.asciidoc @@ -180,6 +180,11 @@ view class the Codename One Android runtime implements (android2cn1:E0201)`. `ForegroundService`. Notifications and channels posted through `NotificationManager` and `NotificationCompat` are shown as local notifications. +* Document intents: `ACTION_OPEN_DOCUMENT`, `ACTION_CREATE_DOCUMENT` and + `ACTION_GET_CONTENT` for types other than images and video open the + platform's Open and Save dialogs, through `startActivityForResult` or the + `OpenDocument`, `OpenMultipleDocuments`, `CreateDocument`, `GetContent` and + `GetMultipleContents` contracts. See <>. * The `AndroidKeyStore` with AES keys from `KeyGenerator` and `KeyGenParameterSpec`, used through `Cipher` in GCM or CBC mode. @@ -491,6 +496,90 @@ the gallery and automated tests cover a subset of those behaviors. `prefixText` and `suffixText` of a text field aren't shown; the build's compatibility report lists the ones an application uses. +[[android-interop-documents]] +=== Opening and creating documents + +An opened document arrives as a `file:` URI that `ContentResolver.openInputStream()` +reads; with `EXTRA_ALLOW_MULTIPLE` every chosen document is in the result's +`getClipData()`. `takePersistableUriPermission()` has nothing to do, because +the application can read the file again later. + +Creating a document works in a different order than on Android. The iOS +document picker can only export a file that already exists, so +`ACTION_CREATE_DOCUMENT` answers at once with a file of the requested name in +the application's storage. The Save dialog opens when the application closes +the stream it wrote through `ContentResolver.openOutputStream()`, and places +the file where the user chooses. The application has already received +`RESULT_OK` by then, so it isn't told when the user cancels the Save dialog. +Write the document straight away in the result callback, as most applications +already do. + +[[android-interop-background]] +=== Background execution on iOS + +Android lets an application keep running in the background through a foreground +service, and schedules deferred work through WorkManager. iOS has neither. An +application in the background is suspended within seconds unless it declared +one of a few background modes, and deferred work runs when the system chooses. +The runtime maps each Android mechanism to the nearest iOS one: + +[cols="1,2",options="header"] +|=== +| Android | On iOS + +| `LocationManager` updates in a foreground service (track recording, navigation) +| Continue in the background. A manifest with `ACCESS_BACKGROUND_LOCATION` or + `FOREGROUND_SERVICE_LOCATION` adds the `location` background mode and the + location usage descriptions, and the location stream then keeps the + application running while it's in the background. The blue location + indicator is shown, and the stream stops if the user force-quits the + application. + +| Other work in a foreground service (`startForeground()`) +| Runs in a background execution window that iOS ends after about thirty + seconds, with the notification shown as a local notification. Long downloads + and processing don't survive in the background; the next section shows the + alternatives. + +| WorkManager work while the application runs +| Runs at once, in the application process, as on Android. + +| WorkManager work with charging, idle, battery or storage constraints +| Handed to Codename One's `BackgroundWork`, which uses `BGTaskScheduler`. iOS + decides when to run it, often overnight while charging, and gives it a + limited time. + +| Periodic work +| Runs on its interval while the application runs. In the background it + runs only when it has one of the constraints above, at times iOS chooses. + +| Work left when the application was closed +| Saved, and resumed the next time the application calls + `WorkManager.getInstance()`. + +| `PendingIntent` location updates, geofences, boot receivers +| Not available. Use `com.codename1.location.LocationManager` + `setBackgroundLocationListener()` or geofencing, which iOS can deliver to an + application that isn't running. +|=== + +==== Porting long-running work + +* *Large downloads*: download while the application is open, in pieces the + worker can resume from (HTTP range requests). WorkManager saves the request, + so a download interrupted by suspension continues from the last piece when + the application returns, rather than starting over. +* *Recording while the screen is off*: keep it tied to the location stream. + As long as location updates run, the code they call runs, so write each fix + to the database from the location listener rather than from a separate + worker thread that waits on a timer. +* *Processing that can wait*: give the WorkManager request a constraint such + as `setRequiresCharging(true)` so that it goes to `BGTaskScheduler`, and make + the worker able to stop part way and resume. + +On Android the same application keeps its foreground services and WorkManager +behaviour unchanged. + === Mixing Codename One and Android code Any Android view can be placed in a Codename One form: its peer is an ordinary diff --git a/maven/android-compat/src/main/java/android/app/ActivityThread.java b/maven/android-compat/src/main/java/android/app/ActivityThread.java index be573778672..5b26b9dc6ee 100644 --- a/maven/android-compat/src/main/java/android/app/ActivityThread.java +++ b/maven/android-compat/src/main/java/android/app/ActivityThread.java @@ -251,6 +251,24 @@ static boolean navigateUpTo(Activity a, Intent upIntent) { // ------------------------------------------------------------ starting + /// A document intent goes to the platform's Open or Save dialog; the + /// result reaches the caller -- or the instance that replaced it, when it + /// was recreated with the dialog open -- as one from another activity + /// would, through `onActivityResult` or the fragment that asked. + private static void startDocumentIntent(Context caller, Intent intent, final int requestCode) { + final Activity source = caller instanceof Activity ? (Activity) caller : null; + com.codename1.androidcompat.runtime.DocumentIntents.start(intent, + new com.codename1.androidcompat.runtime.DocumentIntents.Result() { + @Override + public void onResult(int resultCode, Intent data) { + Activity target = currentInstance(source); + if (target != null && requestCode >= 0) { + target.dispatchActivityResult(requestCode, resultCode, data); + } + } + }); + } + /// Starts the activity `intent` names, from `caller` (null at launch). public static void startActivity(Context caller, Intent intent, int requestCode) { AndroidRuntime rt = AndroidRuntime.getInstance(); @@ -270,6 +288,12 @@ public static void startActivity(Context caller, Intent intent, int requestCode) // its Intent after startActivity() must not change getIntent() or // what onNewIntent() was given. intent = snapshotIntent(intent); + if (intent.getComponent() == null && intent.getComponentClass() == null + && com.codename1.androidcompat.runtime.DocumentIntents.handles(intent) + && rt.getApp().activityForIntent(intent) == null) { + startDocumentIntent(caller, intent, requestCode); + return; + } // Activities start on the EDT, which is the main looper's thread. android.os.Looper.prepareMainLooper(); // A component's package, and an implicit intent's setPackage(), are diff --git a/maven/android-compat/src/main/java/android/content/ClipData.java b/maven/android-compat/src/main/java/android/content/ClipData.java new file mode 100644 index 00000000000..37ed336187c --- /dev/null +++ b/maven/android-compat/src/main/java/android/content/ClipData.java @@ -0,0 +1,91 @@ +/* + * 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 android.content; + +import android.net.Uri; + +import java.util.ArrayList; + +/// The items of a multiple selection, as a document picker returns them in +/// `Intent#getClipData()`. Only what that needs: text and URI items, no +/// clipboard. +public class ClipData { + private final CharSequence label; + private final ArrayList items = new ArrayList(); + + public ClipData(CharSequence label, String[] mimeTypes, Item item) { + this.label = label; + items.add(item); + } + + public static ClipData newRawUri(CharSequence label, Uri uri) { + return new ClipData(label, new String[] {"text/uri-list"}, new Item(uri)); + } + + public static ClipData newUri(ContentResolver resolver, CharSequence label, Uri uri) { + return newRawUri(label, uri); + } + + public static ClipData newPlainText(CharSequence label, CharSequence text) { + return new ClipData(label, new String[] {"text/plain"}, new Item(text)); + } + + public void addItem(Item item) { + items.add(item); + } + + public int getItemCount() { + return items.size(); + } + + public Item getItemAt(int index) { + return items.get(index); + } + + public CharSequence getLabel() { + return label; + } + + public static class Item { + private final CharSequence text; + private final Uri uri; + + public Item(CharSequence text) { + this.text = text; + this.uri = null; + } + + public Item(Uri uri) { + this.text = null; + this.uri = uri; + } + + public CharSequence getText() { + return text; + } + + public Uri getUri() { + return uri; + } + } +} diff --git a/maven/android-compat/src/main/java/android/content/ContentResolver.java b/maven/android-compat/src/main/java/android/content/ContentResolver.java index fa5c251aafd..e40763b98e0 100644 --- a/maven/android-compat/src/main/java/android/content/ContentResolver.java +++ b/maven/android-compat/src/main/java/android/content/ContentResolver.java @@ -48,12 +48,37 @@ public final java.io.InputStream openInputStream(Uri uri) throws java.io.FileNot public final java.io.OutputStream openOutputStream(Uri uri) throws java.io.FileNotFoundException { String path = storagePath(uri); try { - return com.codename1.io.FileSystemStorage.getInstance().openOutputStream(path); + return com.codename1.androidcompat.runtime.DocumentIntents.wrapForExport(path, + com.codename1.io.FileSystemStorage.getInstance().openOutputStream(path)); } catch (java.io.IOException e) { throw new java.io.FileNotFoundException(uri.toString()); } } + /// `mode` ("w", "wt", "wa", "rw") is not distinguished: the stream + /// replaces the file's content, except that "wa" appends. + public final java.io.OutputStream openOutputStream(Uri uri, String mode) throws java.io.FileNotFoundException { + if (mode != null && mode.indexOf('a') >= 0) { + String path = storagePath(uri); + try { + return com.codename1.androidcompat.runtime.DocumentIntents.wrapForExport(path, + com.codename1.io.FileSystemStorage.getInstance().openOutputStream(path, + (int) com.codename1.io.FileSystemStorage.getInstance().getLength(path))); + } catch (java.io.IOException e) { + throw new java.io.FileNotFoundException(uri.toString()); + } + } + return openOutputStream(uri); + } + + /// Documents picked through `ACTION_OPEN_DOCUMENT` are plain files the + /// application can always read again, so a persisted grant is a no-op. + public void takePersistableUriPermission(Uri uri, int modeFlags) { + } + + public void releasePersistableUriPermission(Uri uri, int modeFlags) { + } + /// The `FileSystemStorage` path for a `file:` URI or a bare absolute path. /// A `file:` URI keeps its authority: a sandboxed port's app home is /// `file://home/`, and its path alone (`/files/x`) names another file. diff --git a/maven/android-compat/src/main/java/android/content/Intent.java b/maven/android-compat/src/main/java/android/content/Intent.java index d51ae3acd3b..010cc880bae 100644 --- a/maven/android-compat/src/main/java/android/content/Intent.java +++ b/maven/android-compat/src/main/java/android/content/Intent.java @@ -46,6 +46,7 @@ public class Intent implements Parcelable, Cloneable { public static final String ACTION_SEND_MULTIPLE = "android.intent.action.SEND_MULTIPLE"; public static final String ACTION_GET_CONTENT = "android.intent.action.GET_CONTENT"; public static final String ACTION_OPEN_DOCUMENT = "android.intent.action.OPEN_DOCUMENT"; + public static final String ACTION_CREATE_DOCUMENT = "android.intent.action.CREATE_DOCUMENT"; public static final String ACTION_WEB_SEARCH = "android.intent.action.WEB_SEARCH"; public static final String ACTION_CHOOSER = "android.intent.action.CHOOSER"; public static final String ACTION_SEARCH = "android.intent.action.SEARCH"; @@ -97,6 +98,8 @@ public class Intent implements Parcelable, Cloneable { public Intent() { } + private ClipData clipData; + public Intent(Intent o) { action = o.action; data = o.data; @@ -107,6 +110,7 @@ public Intent(Intent o) { extras = o.extras == null ? null : new Bundle(o.extras); flags = o.flags; pkg = o.pkg; + clipData = o.clipData; } public Intent(String action) { @@ -185,6 +189,16 @@ public Intent setData(Uri data) { return this; } + /// The URIs a picker returns when several were chosen + /// (`EXTRA_ALLOW_MULTIPLE`), as Android reports them. + public ClipData getClipData() { + return clipData; + } + + public void setClipData(ClipData clip) { + this.clipData = clip; + } + public String getType() { return type; } diff --git a/maven/android-compat/src/main/java/androidx/activity/ComponentActivity.java b/maven/android-compat/src/main/java/androidx/activity/ComponentActivity.java index 86140985b6d..e8bc0a30509 100644 --- a/maven/android-compat/src/main/java/androidx/activity/ComponentActivity.java +++ b/maven/android-compat/src/main/java/androidx/activity/ComponentActivity.java @@ -265,7 +265,8 @@ public void run() { String[] permissions = intent.getStringArrayExtra( ActivityResultContracts.RequestMultiplePermissions.EXTRA_PERMISSIONS); requestPermissions(permissions == null ? new String[0] : permissions, requestCode); - } else if (Intent.ACTION_GET_CONTENT.equals(action)) { + } else if (Intent.ACTION_GET_CONTENT.equals(action) + && !com.codename1.androidcompat.runtime.DocumentIntents.handles(intent)) { pickContent(requestCode, intent.getType()); } else { startActivityForResult(intent, requestCode); diff --git a/maven/android-compat/src/main/java/androidx/activity/result/contract/ActivityResultContracts.java b/maven/android-compat/src/main/java/androidx/activity/result/contract/ActivityResultContracts.java index 71f7fe3b43f..510a0942cd8 100644 --- a/maven/android-compat/src/main/java/androidx/activity/result/contract/ActivityResultContracts.java +++ b/maven/android-compat/src/main/java/androidx/activity/result/contract/ActivityResultContracts.java @@ -148,4 +148,92 @@ public Uri parseResult(int resultCode, Intent intent) { return intent == null || resultCode != Activity.RESULT_OK ? null : intent.getData(); } } + + /// Opens one document of the given MIME types (`ACTION_OPEN_DOCUMENT`) + /// and answers its `Uri`, or null when the user cancels. + public static class OpenDocument extends ActivityResultContract { + @Override + public Intent createIntent(Context context, String[] input) { + return new Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE) + .putExtra(Intent.EXTRA_MIME_TYPES, input).setType("*/*"); + } + + @Override + public Uri parseResult(int resultCode, Intent intent) { + return intent == null || resultCode != Activity.RESULT_OK ? null : intent.getData(); + } + } + + /// Opens several documents and answers their `Uri`s, empty when the + /// user cancels. + public static class OpenMultipleDocuments extends ActivityResultContract> { + @Override + public Intent createIntent(Context context, String[] input) { + return new OpenDocument().createIntent(context, input).putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); + } + + @Override + public java.util.List parseResult(int resultCode, Intent intent) { + return urisOf(resultCode, intent); + } + } + + /// `GetContent` for several items. + public static class GetMultipleContents extends ActivityResultContract> { + @Override + public Intent createIntent(Context context, String input) { + return new GetContent().createIntent(context, input).putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); + } + + @Override + public java.util.List parseResult(int resultCode, Intent intent) { + return urisOf(resultCode, intent); + } + } + + /// Creates a document of a MIME type (`ACTION_CREATE_DOCUMENT`); the + /// input is the suggested file name. Answers the `Uri` to write, or null + /// when the user cancels. + public static class CreateDocument extends ActivityResultContract { + private final String mimeType; + + public CreateDocument(String mimeType) { + this.mimeType = mimeType; + } + + /// The deprecated form, for any type. + public CreateDocument() { + this("*/*"); + } + + @Override + public Intent createIntent(Context context, String input) { + return new Intent(Intent.ACTION_CREATE_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE) + .setType(mimeType).putExtra(Intent.EXTRA_TITLE, input); + } + + @Override + public Uri parseResult(int resultCode, Intent intent) { + return intent == null || resultCode != Activity.RESULT_OK ? null : intent.getData(); + } + } + + static java.util.List urisOf(int resultCode, Intent intent) { + java.util.ArrayList out = new java.util.ArrayList(); + if (intent == null || resultCode != Activity.RESULT_OK) { + return out; + } + android.content.ClipData clip = intent.getClipData(); + if (clip != null) { + for (int i = 0; i < clip.getItemCount(); i++) { + Uri u = clip.getItemAt(i).getUri(); + if (u != null) { + out.add(u); + } + } + } else if (intent.getData() != null) { + out.add(intent.getData()); + } + return out; + } } diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/AndroidRuntime.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/AndroidRuntime.java index 3d4fe305c9a..253c237cb9e 100644 --- a/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/AndroidRuntime.java +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/AndroidRuntime.java @@ -230,7 +230,7 @@ public boolean canResolve(Intent intent) { if (action == null) { return false; } - if (app.activityForIntent(intent) != null) { + if (app.activityForIntent(intent) != null || DocumentIntents.handles(intent)) { return true; } // The same test startActivity() applies, without acting on it, so a diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/DocumentIntents.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/DocumentIntents.java new file mode 100644 index 00000000000..f81243deea1 --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/DocumentIntents.java @@ -0,0 +1,227 @@ +/* + * 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.androidcompat.runtime; + +import android.content.Intent; +import android.net.Uri; +import com.codename1.io.FileSystemStorage; +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.IOException; +import java.io.OutputStream; +import java.util.HashMap; +import java.util.Map; + +/// The Storage Access Framework's document intents, `ACTION_OPEN_DOCUMENT`, +/// `ACTION_GET_CONTENT` for a non-media type and `ACTION_CREATE_DOCUMENT`, +/// answered by the platform's own Open and Save dialogs +/// (`Display#showOpenDialog`, `Display#showSaveDialog`). +/// +/// Opening answers the picked file as a `file:` URI the `ContentResolver` +/// reads; several when `EXTRA_ALLOW_MULTIPLE` is set, in the result's +/// `ClipData` as Android puts them. +/// +/// Creating cannot work the way Android does. Android hands the application +/// a writable document the user has already placed; on iOS the document +/// picker can only *export* a file that exists. So `ACTION_CREATE_DOCUMENT` +/// answers at once with a staging file of the requested name, and when the +/// application closes the stream it opened on that URI the Save dialog is +/// shown to put the written file where the user chooses. The visible +/// difference is the order (the file is written, then placed) and that a +/// cancelled Save is not reported back: the application has already seen +/// `RESULT_OK`. +public final class DocumentIntents { + + /// Staging path to the export it is waiting for: name and MIME type. + private static final Map PENDING_EXPORTS = new HashMap(); + private static int stagingCounter; + + private DocumentIntents() { + } + + /// Whether `intent` is a document intent this class answers. + public static boolean handles(Intent intent) { + String action = intent.getAction(); + if (Intent.ACTION_OPEN_DOCUMENT.equals(action) || Intent.ACTION_CREATE_DOCUMENT.equals(action)) { + return true; + } + // GET_CONTENT for images and videos goes to the gallery (see + // ComponentActivity.pickContent); anything else is a document. + return Intent.ACTION_GET_CONTENT.equals(action) && !isMediaType(intent.getType()); + } + + static boolean isMediaType(String type) { + return type != null && (type.regionMatches(true, 0, "image/", 0, 6) + || type.regionMatches(true, 0, "video/", 0, 6)); + } + + /// The result of a document intent: Android's result code and data. + public interface Result { + void onResult(int resultCode, Intent data); + } + + /// Shows the dialog for `intent` and reports what the user picked, on + /// the EDT. `resultCode` is `Activity.RESULT_OK` (-1) or + /// `RESULT_CANCELED` (0). + public static void start(final Intent intent, final Result result) { + if (Intent.ACTION_CREATE_DOCUMENT.equals(intent.getAction())) { + create(intent, result); + return; + } + OpenFileRequest request = new OpenFileRequest() + .multiSelect(intent.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false)); + FileTypeFilter filter = filterFor(intent); + if (filter != null) { + request.addFilter(filter); + } + Display.getInstance().showOpenDialog(request, new SuccessCallback() { + @Override + public void onSucess(FileDialogResult value) { + String[] paths = value == null || value.isCancelled() || value.getError() != null + ? null : value.getPaths(); + if (paths == null || paths.length == 0) { + result.onResult(0, null); + return; + } + Intent data = new Intent(); + data.setData(toUri(paths[0])); + if (paths.length > 1 || intent.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false)) { + android.content.ClipData clip = android.content.ClipData.newRawUri(null, toUri(paths[0])); + for (int i = 1; i < paths.length; i++) { + clip.addItem(new android.content.ClipData.Item(toUri(paths[i]))); + } + data.setClipData(clip); + } + data.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + result.onResult(-1, data); + } + }); + } + + private static void create(Intent intent, Result result) { + String name = intent.getStringExtra(Intent.EXTRA_TITLE); + if (name == null || name.length() == 0) { + name = "Untitled"; + } + // Any separator would leave the staging directory. + name = name.replace('/', '_').replace('\\', '_'); + FileSystemStorage fs = FileSystemStorage.getInstance(); + String dir = fs.getAppHomePath() + "cn1-documents/" + (++stagingCounter) + "-" + + System.currentTimeMillis() + "/"; + fs.mkdir(fs.getAppHomePath() + "cn1-documents"); + fs.mkdir(dir); + String path = dir + name; + PENDING_EXPORTS.put(path, new String[] {name, intent.getType()}); + Intent data = new Intent(); + data.setData(toUri(path)); + data.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + result.onResult(-1, data); + } + + /// Wraps the stream the `ContentResolver` opened on `path`: for a + /// document `ACTION_CREATE_DOCUMENT` staged, closing it shows the Save + /// dialog that places the file. Any other stream comes back unchanged. + public static OutputStream wrapForExport(final String path, OutputStream out) { + if (!PENDING_EXPORTS.containsKey(path)) { + return out; + } + return new java.io.FilterOutputStream(out) { + private boolean closed; + + @Override + public void write(byte[] b, int off, int len) throws IOException { + out.write(b, off, len); + } + + @Override + public void close() throws IOException { + if (closed) { + return; + } + closed = true; + super.close(); + export(path); + } + }; + } + + private static void export(final String path) { + final String[] pending = PENDING_EXPORTS.remove(path); + if (pending == null) { + return; + } + Runnable show = new Runnable() { + @Override + public void run() { + SaveFileRequest request = new SaveFileRequest().suggestedName(pending[0]).sourceFile(path); + if (pending[1] != null && pending[1].indexOf('*') < 0) { + request.addFilter(new FileTypeFilter(pending[1]).mimeTypes(pending[1])); + } + Display.getInstance().showSaveDialog(request, new SuccessCallback() { + @Override + public void onSucess(FileDialogResult value) { + FileSystemStorage.getInstance().delete(path); + } + }); + } + }; + // Usually closed on the EDT, where the dialog is shown at once; a + // stream written on a worker thread shows it on the EDT. + if (Display.getInstance().isEdt()) { + show.run(); + } else { + Display.getInstance().callSerially(show); + } + } + + /// The filter for the intent's MIME type, or its `EXTRA_MIME_TYPES`; + /// null for any type. + private static FileTypeFilter filterFor(Intent intent) { + String[] types = intent.getStringArrayExtra(Intent.EXTRA_MIME_TYPES); + if (types == null || types.length == 0) { + String type = intent.getType(); + types = type == null ? null : new String[] {type}; + } + if (types == null) { + return null; + } + for (String t : types) { + if (t == null || "*/*".equals(t)) { + return null; + } + } + return new FileTypeFilter("Documents").mimeTypes(types); + } + + private static Uri toUri(String path) { + if (path.startsWith("file:")) { + return Uri.parse(path); + } + return Uri.parse("file://" + path); + } +} diff --git a/maven/android-compat/src/test/java/androidx/activity/DocumentIntentsTest.java b/maven/android-compat/src/test/java/androidx/activity/DocumentIntentsTest.java new file mode 100644 index 00000000000..c7d175a88e9 --- /dev/null +++ b/maven/android-compat/src/test/java/androidx/activity/DocumentIntentsTest.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 androidx.activity; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; + +import androidx.activity.result.ActivityResultCallback; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; + +import com.codename1.androidcompat.testing.AndroidTestSupport; +import com.codename1.compat.testing.HeadlessImplementation; +import com.codename1.compat.testing.MainThreadRule; +import com.codename1.io.filechooser.FileDialogResult; + +import org.junit.After; +import org.junit.Rule; +import org.junit.Test; + +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; + +/// `ACTION_OPEN_DOCUMENT` and `ACTION_CREATE_DOCUMENT` threw +/// ActivityNotFoundException; they now go to the platform's Open and Save +/// dialogs, by `startActivityForResult` and by the AndroidX contracts. +public class DocumentIntentsTest { + + @Rule + public final MainThreadRule mainThread = new MainThreadRule(); + + @After + public void reset() { + HeadlessImplementation.openRequest = null; + HeadlessImplementation.openCallback = null; + HeadlessImplementation.saveRequest = null; + HeadlessImplementation.saveCallback = null; + } + + static final class ResultActivity extends ComponentActivity { + final List results = new ArrayList(); + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + results.add(new Object[] {Integer.valueOf(requestCode), Integer.valueOf(resultCode), data}); + } + } + + @Test + public void documentIntentsResolve() { + Context c = AndroidTestSupport.context(); + Intent open = new Intent(Intent.ACTION_OPEN_DOCUMENT).setType("application/gpx+xml"); + assertNotNull(open.resolveActivity(c.getPackageManager())); + Intent create = new Intent(Intent.ACTION_CREATE_DOCUMENT).setType("text/plain"); + assertNotNull(create.resolveActivity(c.getPackageManager())); + } + + @Test + public void openDocumentAnswersThePickedFile() { + AndroidTestSupport.context(); + ResultActivity a = new ResultActivity(); + Intent open = new Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE) + .setType("application/gpx+xml"); + a.startActivityForResult(open, 7); + assertNotNull("no Open dialog was shown", HeadlessImplementation.openCallback); + assertArrayEquals(new String[] {"application/gpx+xml"}, + HeadlessImplementation.openRequest.getFilters()[0].getMimeTypes()); + HeadlessImplementation.openCallback.onSucess(new FileDialogResult(HeadlessImplementation.openRequest, + new String[] {"file:///docs/track.gpx"}, -1, null)); + assertEquals(1, a.results.size()); + assertEquals(Integer.valueOf(7), a.results.get(0)[0]); + assertEquals(Integer.valueOf(Activity.RESULT_OK), a.results.get(0)[1]); + assertEquals("file:///docs/track.gpx", ((Intent) a.results.get(0)[2]).getData().toString()); + } + + @Test + public void cancellingAnswersCanceled() { + AndroidTestSupport.context(); + ResultActivity a = new ResultActivity(); + a.startActivityForResult(new Intent(Intent.ACTION_OPEN_DOCUMENT).setType("*/*"), 3); + assertEquals("*/* must not filter", 0, HeadlessImplementation.openRequest.getFilters().length); + HeadlessImplementation.openCallback.onSucess(FileDialogResult.cancelled(HeadlessImplementation.openRequest)); + assertEquals(Integer.valueOf(Activity.RESULT_CANCELED), a.results.get(0)[1]); + assertNull(a.results.get(0)[2]); + } + + @Test + public void openMultipleDocumentsAnswersEveryUri() { + AndroidTestSupport.context(); + final List> got = new ArrayList>(); + ComponentActivity a = new ComponentActivity(); + ActivityResultLauncher l = a.registerForActivityResult( + new ActivityResultContracts.OpenMultipleDocuments(), new ActivityResultCallback>() { + @Override + public void onActivityResult(List result) { + got.add(result); + } + }); + l.launch(new String[] {"*/*"}); + assertTrue(HeadlessImplementation.openRequest.isMultiSelect()); + HeadlessImplementation.openCallback.onSucess(new FileDialogResult(HeadlessImplementation.openRequest, + new String[] {"file:///a.gpx", "file:///b.gpx"}, -1, null)); + assertEquals(1, got.size()); + assertEquals(2, got.get(0).size()); + assertEquals("file:///b.gpx", got.get(0).get(1).toString()); + } + + /// A non-media GetContent is a document, not a gallery pick. + @Test + public void getContentForADocumentTypeOpensTheOpenDialog() { + AndroidTestSupport.context(); + HeadlessImplementation.gallery = null; + ComponentActivity a = new ComponentActivity(); + final List got = new ArrayList(); + a.registerForActivityResult(new ActivityResultContracts.GetContent(), new ActivityResultCallback() { + @Override + public void onActivityResult(Uri result) { + got.add(result); + } + }).launch("application/pdf"); + assertNull("a PDF went to the gallery", HeadlessImplementation.gallery); + HeadlessImplementation.openCallback.onSucess(new FileDialogResult(HeadlessImplementation.openRequest, + new String[] {"file:///r.pdf"}, -1, null)); + assertEquals("file:///r.pdf", got.get(0).toString()); + } + + /// Creating answers a staging file at once; closing the stream written + /// to it shows the Save dialog that exports it, under the requested name. + @Test + public void createDocumentExportsTheWrittenFileOnClose() throws Exception { + AndroidTestSupport.context(); + ComponentActivity a = new ComponentActivity(); + final List got = new ArrayList(); + a.registerForActivityResult(new ActivityResultContracts.CreateDocument("application/gpx+xml"), + new ActivityResultCallback() { + @Override + public void onActivityResult(Uri result) { + got.add(result); + } + }).launch("track.gpx"); + assertEquals(1, got.size()); + Uri uri = got.get(0); + assertTrue(uri.toString(), uri.toString().endsWith("/track.gpx")); + assertNull("the dialog was shown before anything was written", HeadlessImplementation.saveRequest); + + OutputStream out = a.getContentResolver().openOutputStream(uri); + out.write("".getBytes("UTF-8")); + out.close(); + MainThreadRule.drain(); + assertNotNull("closing did not show the Save dialog", HeadlessImplementation.saveRequest); + assertEquals("track.gpx", HeadlessImplementation.saveRequest.getSuggestedName()); + assertEquals("", new String(HeadlessImplementation.FILES.get( + HeadlessImplementation.saveRequest.getSourceFile()), "UTF-8")); + + HeadlessImplementation.saveRequest = null; + out.close(); + MainThreadRule.drain(); + assertNull("a second close exported again", HeadlessImplementation.saveRequest); + } +} diff --git a/maven/compat-testing/src/main/java/com/codename1/compat/testing/HeadlessImplementation.java b/maven/compat-testing/src/main/java/com/codename1/compat/testing/HeadlessImplementation.java index 622de8090f9..41cfa723707 100644 --- a/maven/compat-testing/src/main/java/com/codename1/compat/testing/HeadlessImplementation.java +++ b/maven/compat-testing/src/main/java/com/codename1/compat/testing/HeadlessImplementation.java @@ -75,6 +75,31 @@ public void openGallery(com.codename1.ui.events.ActionListener response, int typ gallery = response; } + /// The request and callback of the last `showOpenDialog` call, which the + /// headless implementation never answers itself. A test that reads them + /// must reset them. + public static com.codename1.io.filechooser.OpenFileRequest openRequest; + public static com.codename1.util.SuccessCallback openCallback; + + @Override + public void showOpenDialog(com.codename1.io.filechooser.OpenFileRequest request, + com.codename1.util.SuccessCallback callback) { + openRequest = request; + openCallback = callback; + } + + /// The request and callback of the last `showSaveDialog` call, as for + /// `#openRequest`. + public static com.codename1.io.filechooser.SaveFileRequest saveRequest; + public static com.codename1.util.SuccessCallback saveCallback; + + @Override + public void showSaveDialog(com.codename1.io.filechooser.SaveFileRequest request, + com.codename1.util.SuccessCallback callback) { + saveRequest = request; + saveCallback = callback; + } + /// The image encoder `ImageIO.getImageIO()` answers; none by default. A /// test that sets it must reset it. public static com.codename1.ui.util.ImageIO imageIO; From 4060da4ef10ca2a016977514cfd4df68a76446e0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:33:14 +0200 Subject: [PATCH 2/3] Android compat: address red-team review of document intents Encode-safe staging names, async CREATE_DOCUMENT result, staging cleanup, safe append mode, wildcard filters; guide no longer states an unverified background window length. Co-Authored-By: Claude Opus 5.5 --- docs/developer-guide/Android-Interop.asciidoc | 4 +- .../java/android/content/ContentResolver.java | 12 ++- .../runtime/DocumentIntents.java | 80 ++++++++++++++++--- .../activity/DocumentIntentsTest.java | 31 +++++++ 4 files changed, 111 insertions(+), 16 deletions(-) diff --git a/docs/developer-guide/Android-Interop.asciidoc b/docs/developer-guide/Android-Interop.asciidoc index 168d3bfbc8d..a5b088e3879 100644 --- a/docs/developer-guide/Android-Interop.asciidoc +++ b/docs/developer-guide/Android-Interop.asciidoc @@ -536,8 +536,8 @@ The runtime maps each Android mechanism to the nearest iOS one: application. | Other work in a foreground service (`startForeground()`) -| Runs in a background execution window that iOS ends after about thirty - seconds, with the notification shown as a local notification. Long downloads +| Runs in a background execution window that iOS ends after a short time, + with the notification shown as a local notification. Long downloads and processing don't survive in the background; the next section shows the alternatives. diff --git a/maven/android-compat/src/main/java/android/content/ContentResolver.java b/maven/android-compat/src/main/java/android/content/ContentResolver.java index e40763b98e0..f83e544200e 100644 --- a/maven/android-compat/src/main/java/android/content/ContentResolver.java +++ b/maven/android-compat/src/main/java/android/content/ContentResolver.java @@ -60,10 +60,18 @@ public final java.io.OutputStream openOutputStream(Uri uri) throws java.io.FileN public final java.io.OutputStream openOutputStream(Uri uri, String mode) throws java.io.FileNotFoundException { if (mode != null && mode.indexOf('a') >= 0) { String path = storagePath(uri); + com.codename1.io.FileSystemStorage fs = com.codename1.io.FileSystemStorage.getInstance(); + long length = fs.exists(path) ? fs.getLength(path) : 0; + if (length <= 0) { + // Nothing to append to: a new or empty file is a plain write. + return openOutputStream(uri); + } + if (length > Integer.MAX_VALUE) { + throw new java.io.FileNotFoundException("Cannot append past 2 GB: " + uri); + } try { return com.codename1.androidcompat.runtime.DocumentIntents.wrapForExport(path, - com.codename1.io.FileSystemStorage.getInstance().openOutputStream(path, - (int) com.codename1.io.FileSystemStorage.getInstance().getLength(path))); + fs.openOutputStream(path, (int) length)); } catch (java.io.IOException e) { throw new java.io.FileNotFoundException(uri.toString()); } diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/DocumentIntents.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/DocumentIntents.java index f81243deea1..7f253f66d18 100644 --- a/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/DocumentIntents.java +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/DocumentIntents.java @@ -54,7 +54,9 @@ /// shown to put the written file where the user chooses. The visible /// difference is the order (the file is written, then placed) and that a /// cancelled Save is not reported back: the application has already seen -/// `RESULT_OK`. +/// `RESULT_OK`, and Android has no result to revoke it with. The staged +/// file is removed once the dialog closes either way; one never closed is +/// removed when the next run first creates a document. public final class DocumentIntents { /// Staging path to the export it is waiting for: name and MIME type. @@ -123,24 +125,76 @@ public void onSucess(FileDialogResult value) { }); } - private static void create(Intent intent, Result result) { + private static void create(Intent intent, final Result result) { String name = intent.getStringExtra(Intent.EXTRA_TITLE); if (name == null || name.length() == 0) { name = "Untitled"; } - // Any separator would leave the staging directory. - name = name.replace('/', '_').replace('\\', '_'); + String stagedName = stagingName(name); FileSystemStorage fs = FileSystemStorage.getInstance(); - String dir = fs.getAppHomePath() + "cn1-documents/" + (++stagingCounter) + "-" - + System.currentTimeMillis() + "/"; - fs.mkdir(fs.getAppHomePath() + "cn1-documents"); + String root = fs.getAppHomePath() + STAGING_DIR; + if (stagingCounter == 0) { + // Left by an earlier run that never closed its stream: nothing + // can export it any more. + deleteTree(root); + } + String dir = root + (++stagingCounter) + "/"; + fs.mkdir(root); fs.mkdir(dir); - String path = dir + name; - PENDING_EXPORTS.put(path, new String[] {name, intent.getType()}); - Intent data = new Intent(); + String path = dir + stagedName; + PENDING_EXPORTS.put(path, new String[] {name, intent.getType(), dir}); + final Intent data = new Intent(); data.setData(toUri(path)); data.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - result.onResult(-1, data); + // Android delivers a result after the launching call returns, never + // inside it; code that sets state after launch() relies on that. + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + result.onResult(-1, data); + } + }); + } + + private static final String STAGING_DIR = "cn1-documents/"; + + /// The staged file's name: the title with every character a URI or a + /// file system treats specially replaced, so the `file:` URI handed to + /// the application names exactly this file when it comes back. The Save + /// dialog still suggests the title as given. + static String stagingName(String name) { + StringBuilder b = new StringBuilder(name.length()); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + boolean safe = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') + || c == '.' || c == '-' || c == '_'; + b.append(safe ? c : '_'); + } + String s = b.toString(); + return s.startsWith(".") ? "_" + s : s; + } + + private static void deleteTree(String dir) { + FileSystemStorage fs = FileSystemStorage.getInstance(); + if (!fs.exists(dir)) { + return; + } + try { + String[] children = fs.listFiles(dir); + if (children != null) { + for (String c : children) { + String child = dir + c; + if (fs.isDirectory(child)) { + deleteTree(child.endsWith("/") ? child : child + "/"); + } else { + fs.delete(child); + } + } + } + } catch (IOException e) { + // Best effort: a leftover staging file costs space, nothing else. + } + fs.delete(dir); } /// Wraps the stream the `ContentResolver` opened on `path`: for a @@ -186,6 +240,7 @@ public void run() { @Override public void onSucess(FileDialogResult value) { FileSystemStorage.getInstance().delete(path); + FileSystemStorage.getInstance().delete(pending[2]); } }); } @@ -211,7 +266,8 @@ private static FileTypeFilter filterFor(Intent intent) { return null; } for (String t : types) { - if (t == null || "*/*".equals(t)) { + // A list that also admits anything filters nothing. + if (t == null || "*/*".equals(t) || "*".equals(t)) { return null; } } diff --git a/maven/android-compat/src/test/java/androidx/activity/DocumentIntentsTest.java b/maven/android-compat/src/test/java/androidx/activity/DocumentIntentsTest.java index c7d175a88e9..6754a0a9cf2 100644 --- a/maven/android-compat/src/test/java/androidx/activity/DocumentIntentsTest.java +++ b/maven/android-compat/src/test/java/androidx/activity/DocumentIntentsTest.java @@ -167,6 +167,8 @@ public void onActivityResult(Uri result) { got.add(result); } }).launch("track.gpx"); + assertEquals("the result was delivered inside launch()", 0, got.size()); + MainThreadRule.drain(); assertEquals(1, got.size()); Uri uri = got.get(0); assertTrue(uri.toString(), uri.toString().endsWith("/track.gpx")); @@ -186,4 +188,33 @@ public void onActivityResult(Uri result) { MainThreadRule.drain(); assertNull("a second close exported again", HeadlessImplementation.saveRequest); } + + /// A title with characters a URI treats specially still names the file + /// the stream goes to, so the export is not lost. + @Test + public void createDocumentWithSpecialCharactersStillExports() throws Exception { + AndroidTestSupport.context(); + ResultActivity a = new ResultActivity(); + a.startActivityForResult(new Intent(Intent.ACTION_CREATE_DOCUMENT).setType("text/plain") + .putExtra(Intent.EXTRA_TITLE, "Report #3 50%?.txt"), 4); + MainThreadRule.drain(); + Uri uri = ((Intent) a.results.get(0)[2]).getData(); + OutputStream out = a.getContentResolver().openOutputStream(uri); + out.write(1); + out.close(); + MainThreadRule.drain(); + assertNotNull("the export was lost", HeadlessImplementation.saveRequest); + assertEquals("Report #3 50%?.txt", HeadlessImplementation.saveRequest.getSuggestedName()); + } + + /// Append mode on a file that does not exist yet is a plain write. + @Test + public void appendModeOnANewFileWrites() throws Exception { + Context c = AndroidTestSupport.context(); + Uri uri = Uri.parse("file:///home/append-new.txt"); + OutputStream out = c.getContentResolver().openOutputStream(uri, "wa"); + out.write("x".getBytes("UTF-8")); + out.close(); + assertEquals("x", new String(HeadlessImplementation.FILES.get("file:///home/append-new.txt"), "UTF-8")); + } } From 14df6b943b2f2b5860bdf9476f982f62c67be4a9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:34:05 +0200 Subject: [PATCH 3/3] Android compat: wildcard document filters open unfiltered; document cancelled save Co-Authored-By: Claude Opus 5.5 --- docs/developer-guide/Android-Interop.asciidoc | 4 +++- .../codename1/androidcompat/runtime/DocumentIntents.java | 9 +++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/developer-guide/Android-Interop.asciidoc b/docs/developer-guide/Android-Interop.asciidoc index a5b088e3879..afae59cf1c7 100644 --- a/docs/developer-guide/Android-Interop.asciidoc +++ b/docs/developer-guide/Android-Interop.asciidoc @@ -510,7 +510,9 @@ document picker can only export a file that already exists, so the application's storage. The Save dialog opens when the application closes the stream it wrote through `ContentResolver.openOutputStream()`, and places the file where the user chooses. The application has already received -`RESULT_OK` by then, so it isn't told when the user cancels the Save dialog. +`RESULT_OK` by then, so it isn't told when the user cancels the Save dialog, +and the document is saved only if the user confirms it. Keep the data until +the application no longer needs it rather than relying on the export. Write the document straight away in the result callback, as most applications already do. diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/DocumentIntents.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/DocumentIntents.java index 7f253f66d18..3f2ef917b54 100644 --- a/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/DocumentIntents.java +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/DocumentIntents.java @@ -239,6 +239,10 @@ public void run() { Display.getInstance().showSaveDialog(request, new SuccessCallback() { @Override public void onSucess(FileDialogResult value) { + // Deleted on a cancel too. The staged copy lives in a + // directory the user cannot reach, so keeping it would + // recover nothing; the guide says a cancelled Save + // exports nothing and is not reported. FileSystemStorage.getInstance().delete(path); FileSystemStorage.getInstance().delete(pending[2]); } @@ -266,8 +270,9 @@ private static FileTypeFilter filterFor(Intent intent) { return null; } for (String t : types) { - // A list that also admits anything filters nothing. - if (t == null || "*/*".equals(t) || "*".equals(t)) { + // A wildcard filters nothing: ports disagree on `text/*`, and a + // picker showing too much beats one that hides the document. + if (t == null || t.indexOf('*') >= 0) { return null; } }