Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions docs/developer-guide/Android-Interop.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<android-interop-documents>>.
* The `AndroidKeyStore` with AES keys from `KeyGenerator` and
`KeyGenParameterSpec`, used through `Cipher` in GCM or CBC mode.

Expand Down Expand Up @@ -491,6 +496,92 @@ 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,
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.

[[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 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.

| 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
Expand Down
24 changes: 24 additions & 0 deletions maven/android-compat/src/main/java/android/app/ActivityThread.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
Expand Down
91 changes: 91 additions & 0 deletions maven/android-compat/src/main/java/android/content/ClipData.java
Original file line number Diff line number Diff line change
@@ -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<Item> items = new ArrayList<Item>();

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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,45 @@ 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);
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,
fs.openOutputStream(path, (int) length));
} 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.
Expand Down
14 changes: 14 additions & 0 deletions maven/android-compat/src/main/java/android/content/Intent.java
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading