From 06bb58f1c40deb63be5b0bec6200d1ec67a75379 Mon Sep 17 00:00:00 2001 From: Rohit Kushwaha Date: Fri, 13 Nov 2026 16:30:45 +0530 Subject: [PATCH 1/3] feat: added api for launchAppwithFile --- .../android/com/foxdebug/system/System.java | 673 +++++++++++++++++- src/plugins/system/system.d.ts | 103 +++ src/plugins/system/www/plugin.js | 111 +++ 3 files changed, 869 insertions(+), 18 deletions(-) diff --git a/src/plugins/system/android/com/foxdebug/system/System.java b/src/plugins/system/android/com/foxdebug/system/System.java index 146755da62..0a09047cb0 100644 --- a/src/plugins/system/android/com/foxdebug/system/System.java +++ b/src/plugins/system/android/com/foxdebug/system/System.java @@ -167,6 +167,7 @@ public boolean execute( case "has-permission": case "open-in-browser": case "launch-app": + case "launch-app-with-file": case "get-global-setting": case "get-available-encodings": case "decode": @@ -617,6 +618,9 @@ public void run() { case "launch-app": launchApp(arg1, arg2, args.optJSONObject(2), callbackContext); break; + case "launch-app-with-file": + launchAppWithFile(args.optJSONObject(0), callbackContext); + break; case "get-global-setting": getGlobalSetting(arg1, callbackContext); break; @@ -1727,36 +1731,654 @@ private void launchApp( intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); intent.setClassName(appId, className); - if (extras != null) { - Iterator keys = extras.keys(); + applyExtras(intent, extras); - while (keys.hasNext()) { - String key = keys.next(); - Object value = extras.get(key); + activity.startActivity(intent); + callback.success("Launched " + appId); + } catch (Exception e) { + callback.error(e.toString()); + } + } - if (value instanceof Integer) { - intent.putExtra(key, (Integer) value); - } else if (value instanceof Boolean) { - intent.putExtra(key, (Boolean) value); - } else if (value instanceof Double) { - intent.putExtra(key, (Double) value); - } else if (value instanceof Long) { - intent.putExtra(key, (Long) value); - } else if (value instanceof String) { - intent.putExtra(key, (String) value); - } else { - intent.putExtra(key, value.toString()); + /** + * Copies caller supplied JSON key/value pairs onto an intent as extras, + * preserving the scalar type of every value. + */ + private void applyExtras(Intent intent, JSONObject extras) + throws JSONException { + if (extras == null) { + return; + } + + Iterator keys = extras.keys(); + + while (keys.hasNext()) { + String key = keys.next(); + putScalarExtra(intent, key, extras.get(key)); + } + } + + /** + * Puts one JSON scalar on the intent through the matching typed overload, so + * the receiving app sees a primitive rather than a stringified value. + */ + private void putScalarExtra(Intent intent, String key, Object value) { + if (value instanceof Integer) { + intent.putExtra(key, (Integer) value); + } else if (value instanceof Boolean) { + intent.putExtra(key, (Boolean) value); + } else if (value instanceof Double) { + intent.putExtra(key, (Double) value); + } else if (value instanceof Long) { + intent.putExtra(key, (Long) value); + } else if (value instanceof String) { + intent.putExtra(key, (String) value); + } else { + intent.putExtra(key, value.toString()); + } + } + + /** + * Like {@link #applyExtras}, but maps JSON arrays onto typed arrays instead + * of stringifying them. + * + * Only the file launch API uses this, so {@code launchApp} keeps turning + * array extras into strings exactly as it always has. + */ + private void applyRichExtras(Intent intent, JSONObject extras) + throws JSONException { + if (extras == null) { + return; + } + + Iterator keys = extras.keys(); + + while (keys.hasNext()) { + String key = keys.next(); + Object value = extras.get(key); + + if (value instanceof JSONArray) { + putArrayExtra(intent, key, (JSONArray) value); + } else { + putScalarExtra(intent, key, value); + } + } + } + + private void putArrayExtra(Intent intent, String key, JSONArray array) { + int length = array.length(); + + if (length == 0) { + intent.putExtra(key, new String[0]); + return; + } + + boolean allBooleans = true; + boolean allIntegral = true; + boolean allNumbers = true; + + for (int i = 0; i < length; i++) { + Object item = array.opt(i); + if (!(item instanceof Boolean)) { + allBooleans = false; + } + if (!(item instanceof Integer) && !(item instanceof Long)) { + allIntegral = false; + } + if (!(item instanceof Number)) { + allNumbers = false; + } + } + + if (allBooleans) { + boolean[] values = new boolean[length]; + for (int i = 0; i < length; i++) { + values[i] = (Boolean) array.opt(i); + } + intent.putExtra(key, values); + return; + } + + if (allIntegral) { + int[] values = new int[length]; + for (int i = 0; i < length; i++) { + values[i] = ((Number) array.opt(i)).intValue(); + } + intent.putExtra(key, values); + return; + } + + if (allNumbers) { + double[] values = new double[length]; + for (int i = 0; i < length; i++) { + values[i] = ((Number) array.opt(i)).doubleValue(); + } + intent.putExtra(key, values); + return; + } + + String[] values = new String[length]; + for (int i = 0; i < length; i++) { + values[i] = String.valueOf(array.opt(i)); + } + intent.putExtra(key, values); + } + + /** + * Launches an activity and hands it content URIs for files owned by this app. + * + * This is the union of {@link #launchApp} (chosen package + activity) and + * {@link #fileAction} (shareable content URIs): the caller picks the target + * instead of the system chooser, and the receiving activity is granted read + * (and optionally write) access to the content URIs. + * + * Versatility rules, all optional: + * - no {@code className}: the best matching activity in {@code app} is resolved. + * - no {@code app}: the intent stays implicit, so the system picks the handler. + * - several files: the default action becomes {@code SEND_MULTIPLE}. + * - no files: the default action becomes {@code MAIN}, i.e. a plain app launch. + */ + private void launchAppWithFile(JSONObject options, CallbackContext callback) { + if (options == null) { + callback.error("No options provided."); + return; + } + + String appId = trimToEmpty(options.optString("app", "")); + String className = trimToEmpty(options.optString("className", "")); + String filename = trimToEmpty(options.optString("filename", "")); + + if (!className.isEmpty() && appId.isEmpty()) { + callback.error("\"className\" requires \"app\" (the package name)."); + return; + } + + List uris = collectFileUris(options, filename); + if (uris == null) { + callback.error("Unable to access one or more files to pass on."); + return; + } + + if (uris.isEmpty() && appId.isEmpty()) { + callback.error("Nothing to launch: provide \"app\" and/or a file."); + return; + } + + String requestedAction = trimToEmpty(options.optString("action", "")); + String intentAction = requestedAction.isEmpty() + ? defaultIntentAction(uris.size()) + : normalizeIntentAction(requestedAction); + + String requestedMime = trimToEmpty(options.optString("mimeType", "")); + String clipLabel = trimToEmpty(options.optString("clipLabel", "")); + boolean newTask = options.optBoolean("newTask", true); + boolean attachAsStream = options.optBoolean("attachAsStream", false); + boolean writeAccess = + options.optBoolean("writeAccess", false) || + Intent.ACTION_EDIT.equals(intentAction); + + Uri primary = uris.isEmpty() ? null : uris.get(0); + + // A content:// URI owned by another app can be unreadable for us, and + // getType() then throws SecurityException. Resolve the type defensively so + // a revoked or foreign grant cannot abort the launch with a raw exception. + String mimeType; + try { + mimeType = resolveMimeType(requestedMime, primary, filename); + } catch (RuntimeException error) { + Log.w(TAG, "Unable to resolve MIME type, falling back to */*", error); + mimeType = "*/*"; + } + + int grantFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION; + if (writeAccess) { + grantFlags |= Intent.FLAG_GRANT_WRITE_URI_PERMISSION; + } + + try { + Intent intent = new Intent(intentAction); + + if (newTask) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + } + + if (primary != null) { + if (Intent.ACTION_SEND_MULTIPLE.equals(intentAction)) { + intent.setType(mimeType); + intent.putParcelableArrayListExtra( + Intent.EXTRA_STREAM, + new ArrayList<>(uris) + ); + } else if (Intent.ACTION_SEND.equals(intentAction)) { + intent.setType(mimeType); + intent.putExtra(Intent.EXTRA_STREAM, primary); + } else { + intent.setDataAndType(primary, mimeType); + if (attachAsStream) { + intent.putExtra(Intent.EXTRA_STREAM, primary); } } + } else if (!requestedMime.isEmpty()) { + intent.setType(mimeType); + } + + applyCategories(intent, options.optJSONArray("categories")); + + if (!appId.isEmpty()) { + intent.setPackage(appId); + + if (!className.isEmpty()) { + intent.setClassName(appId, className); + } else if (!resolveComponentInPackage(intent)) { + callback.error( + "No activity in " + + appId + + " handles " + + intentAction + + " for " + + mimeType + + ". Pass \"className\" to target one explicitly." + ); + return; + } + } + + if (primary != null) { + // ClipData carries the grant over to the data URI on older releases and + // keeps every file visible to the target as a shared item. + intent.setClipData( + buildClipData( + uris, + clipLabel.isEmpty() ? getClipLabel(primary, filename) : clipLabel + ) + ); + intent.addFlags(grantFlags); + } + + applyRichExtras(intent, options.optJSONObject("extras")); + + String flagError = applyFlags(intent, options.optJSONArray("flags")); + if (flagError != null) { + callback.error(flagError); + return; + } + + if (primary != null) { + grantUriPermissions(intent, uris, grantFlags); + + if (!appId.isEmpty()) { + grantUriPermissionsToPackage(appId, uris, grantFlags); + } } activity.startActivity(intent); - callback.success("Launched " + appId); + + callback.success(buildLaunchResult(intent, intentAction, mimeType, uris)); + } catch (ActivityNotFoundException notFound) { + callback.error( + className.isEmpty() + ? "No activity found to handle " + + intentAction + + " for " + + mimeType + + (appId.isEmpty() ? "." : " in " + appId + ".") + : "No activity " + className + " found in package " + appId + "." + ); } catch (Exception e) { callback.error(e.toString()); } } + private static String defaultIntentAction(int fileCount) { + if (fileCount > 1) { + return Intent.ACTION_SEND_MULTIPLE; + } + + return fileCount == 1 ? Intent.ACTION_VIEW : Intent.ACTION_MAIN; + } + + /** + * Resolves every requested file to a shareable content URI, in request order. + * + * @return the URIs, or null when any requested file cannot be exposed. + */ + private List collectFileUris(JSONObject options, String filename) { + List uris = new ArrayList<>(); + String single = trimToEmpty(options.optString("fileUri", "")); + + if (!single.isEmpty()) { + Uri uri = getContentProviderUri(single, filename); + if (uri == null) { + return null; + } + uris.add(uri); + } + + JSONArray many = options.optJSONArray("fileUris"); + + if (many != null) { + for (int i = 0; i < many.length(); i++) { + String entry = trimToEmpty(many.optString(i)); + if (entry.isEmpty()) { + continue; + } + + Uri uri = getContentProviderUri(entry, filename); + if (uri == null) { + return null; + } + + if (!uris.contains(uri)) { + uris.add(uri); + } + } + } + + return uris; + } + + /** + * Chooses the activity inside {@code intent}'s package that handles its + * current data and type, falling back to the package's launch activity only + * for a plain MAIN launch. + * + * Returning false rather than guessing keeps a failed file hand-off from + * silently opening the app's home screen. + */ + private boolean resolveComponentInPackage(Intent intent) { + PackageManager pm = context.getPackageManager(); + ResolveInfo resolved = pm.resolveActivity( + intent, + PackageManager.MATCH_DEFAULT_ONLY + ); + + if (resolved != null && resolved.activityInfo != null) { + intent.setClassName( + resolved.activityInfo.packageName, + resolved.activityInfo.name + ); + return true; + } + + if ( + Intent.ACTION_MAIN.equals(intent.getAction()) && + intent.getData() == null && + intent.getType() == null + ) { + Intent launch = pm.getLaunchIntentForPackage(intent.getPackage()); + ComponentName component = launch == null ? null : launch.getComponent(); + + if (component != null) { + intent.setClassName( + component.getPackageName(), + component.getClassName() + ); + return true; + } + } + + return false; + } + + private void applyCategories(Intent intent, JSONArray categories) { + if (categories == null) { + return; + } + + for (int i = 0; i < categories.length(); i++) { + String name = trimToEmpty(categories.optString(i)); + if (name.isEmpty()) { + continue; + } + intent.addCategory(normalizeCategoryName(name)); + } + } + + private static String normalizeCategoryName(String name) { + if (name.indexOf('.') >= 0) { + return name; + } + + String upper = name.toUpperCase(Locale.US); + + switch (upper) { + case "DEFAULT": + return Intent.CATEGORY_DEFAULT; + case "LAUNCHER": + return Intent.CATEGORY_LAUNCHER; + case "BROWSABLE": + return Intent.CATEGORY_BROWSABLE; + case "OPENABLE": + return Intent.CATEGORY_OPENABLE; + case "HOME": + return Intent.CATEGORY_HOME; + case "PREFERENCE": + return Intent.CATEGORY_PREFERENCE; + case "LEANBACK_LAUNCHER": + return Intent.CATEGORY_LEANBACK_LAUNCHER; + default: + return "android.intent.category." + upper; + } + } + + /** + * Adds caller supplied intent flags, accepting readable names, raw constants + * and plain integers. + * + * @return null on success, or a message naming every unknown flag. + */ + private String applyFlags(Intent intent, JSONArray flags) { + if (flags == null) { + return null; + } + + StringBuilder unknown = new StringBuilder(); + + for (int i = 0; i < flags.length(); i++) { + Object entry = flags.opt(i); + + if (entry == null || entry == JSONObject.NULL) { + continue; + } + + if (entry instanceof Number) { + intent.addFlags(((Number) entry).intValue()); + continue; + } + + String name = trimToEmpty(String.valueOf(entry)); + if (name.isEmpty()) { + continue; + } + + Integer flag = intentFlagFor(normalizeFlagName(name)); + + if (flag == null) { + if (unknown.length() > 0) { + unknown.append(", "); + } + unknown.append(name); + } else { + intent.addFlags(flag); + } + } + + return unknown.length() == 0 + ? null + : "Unknown intent flag(s): " + unknown; + } + + private static String normalizeFlagName(String name) { + String normalized = trimToEmpty(name).toUpperCase(Locale.US); + + if (normalized.startsWith("FLAG_")) { + normalized = normalized.substring(5); + } + + if (normalized.startsWith("ACTIVITY_")) { + normalized = normalized.substring(9); + } + + return normalized; + } + + /** + * Maps a readable flag name onto the matching {@link Intent} constant. + */ + private static Integer intentFlagFor(String name) { + switch (name) { + case "NEW_TASK": + return Intent.FLAG_ACTIVITY_NEW_TASK; + case "SINGLE_TOP": + return Intent.FLAG_ACTIVITY_SINGLE_TOP; + case "CLEAR_TOP": + return Intent.FLAG_ACTIVITY_CLEAR_TOP; + case "CLEAR_TASK": + return Intent.FLAG_ACTIVITY_CLEAR_TASK; + case "MULTIPLE_TASK": + return Intent.FLAG_ACTIVITY_MULTIPLE_TASK; + case "NEW_DOCUMENT": + return Intent.FLAG_ACTIVITY_NEW_DOCUMENT; + case "NO_HISTORY": + return Intent.FLAG_ACTIVITY_NO_HISTORY; + case "NO_ANIMATION": + return Intent.FLAG_ACTIVITY_NO_ANIMATION; + case "EXCLUDE_FROM_RECENTS": + return Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS; + case "REORDER_TO_FRONT": + return Intent.FLAG_ACTIVITY_REORDER_TO_FRONT; + case "TASK_ON_HOME": + return Intent.FLAG_ACTIVITY_TASK_ON_HOME; + case "RESET_TASK_IF_NEEDED": + return Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED; + case "GRANT_READ_URI_PERMISSION": + return Intent.FLAG_GRANT_READ_URI_PERMISSION; + case "GRANT_WRITE_URI_PERMISSION": + return Intent.FLAG_GRANT_WRITE_URI_PERMISSION; + case "GRANT_PERSISTABLE_URI_PERMISSION": + return Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION; + case "GRANT_PREFIX_URI_PERMISSION": + return Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; + default: + return null; + } + } + + private ClipData buildClipData(List uris, String label) { + ClipData clip = ClipData.newUri( + context.getContentResolver(), + label, + uris.get(0) + ); + + for (int i = 1; i < uris.size(); i++) { + clip.addItem(new ClipData.Item(uris.get(i))); + } + + return clip; + } + + private JSONObject buildLaunchResult( + Intent intent, + String action, + String mimeType, + List uris + ) throws JSONException { + JSONArray fileUris = new JSONArray(); + for (Uri uri : uris) { + fileUris.put(uri.toString()); + } + + ComponentName component = intent.getComponent(); + + if (component == null) { + ResolveInfo resolved = context + .getPackageManager() + .resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY); + + if (resolved != null && resolved.activityInfo != null) { + component = new ComponentName( + resolved.activityInfo.packageName, + resolved.activityInfo.name + ); + } + } + + JSONObject result = new JSONObject(); + result.put("action", action); + result.put("mimeType", mimeType); + result.put("uris", fileUris); + result.put("uri", uris.isEmpty() ? "" : uris.get(0).toString()); + result.put( + "component", + component == null ? "" : component.flattenToString() + ); + result.put("app", component == null ? "" : component.getPackageName()); + result.put( + "className", + component == null ? "" : component.getClassName() + ); + + return result; + } + + private static String trimToEmpty(String value) { + return value == null ? "" : value.trim(); + } + + private static String normalizeIntentAction(String action) { + String normalized = trimToEmpty(action); + + if (normalized.isEmpty()) { + return Intent.ACTION_VIEW; + } + + if (normalized.indexOf('.') < 0) { + return "android.intent.action." + normalized.toUpperCase(Locale.US); + } + + return normalized; + } + + private String getClipLabel(Uri uri, String filename) { + String label = null; + + if (filename != null && !filename.isEmpty()) { + label = new File(filename).getName(); + } + + if (label == null || label.isEmpty()) { + label = uri.getLastPathSegment(); + } + + if (label == null || label.isEmpty()) { + label = "shared-file"; + } + + return label; + } + + /** + * Grants every content URI to the package that will receive the intent. + * + * An explicit component does not have to match any intent filter, so + * resolving the intent can legitimately come back empty; this direct grant is + * what makes explicit-component hand-off work. + */ + private void grantUriPermissionsToPackage( + String targetPackage, + List uris, + int flags + ) { + for (Uri uri : uris) { + try { + context.grantUriPermission(targetPackage, uri, flags); + } catch (SecurityException error) { + Log.w(TAG, "Unable to grant URI permission to " + targetPackage, error); + } + } + } + private void addShortcut( String id, String label, @@ -2256,6 +2878,21 @@ private void grantUriPermissions(Intent intent, Uri uri, int flags) { } } + private void grantUriPermissions(Intent intent, List uris, int flags) { + if (uris == null || uris.isEmpty()) return; + PackageManager pm = context.getPackageManager(); + List resInfoList = pm.queryIntentActivities( + intent, + PackageManager.MATCH_DEFAULT_ONLY + ); + for (ResolveInfo resolveInfo : resInfoList) { + String packageName = resolveInfo.activityInfo.packageName; + for (Uri uri : uris) { + context.grantUriPermission(packageName, uri, flags); + } + } + } + private String resolveMimeType(String currentMime, Uri uri, String filename) { if ( currentMime != null && diff --git a/src/plugins/system/system.d.ts b/src/plugins/system/system.d.ts index ed15e281e1..4114966a15 100644 --- a/src/plugins/system/system.d.ts +++ b/src/plugins/system/system.d.ts @@ -60,6 +60,77 @@ type FileAction = 'VIEW' | 'EDIT' | 'SEND' | 'RUN'; type OnFail = (err: string) => void; type OnSuccessBool = (res: boolean) => void; +interface LaunchAppWithFileOptions { + /** Package name of the target app, e.g. `org.love2d.android`. Omit to let the system choose. */ + app?: string; + /** + * Fully qualified activity class name, e.g. `org.love2d.android.GameActivity`. + * Omit to resolve the best matching activity inside `app`. Requires `app`. + */ + className?: string; + /** + * `file://` URL of a file to hand over, or an existing `content://` URI. + * + * A `file://` path is exposed through the app's FileProvider (copied to the + * cache first when it lives outside the shareable roots). A `content://` URI + * is passed through unchanged and re-granted to the target, so it stays + * readable only while this app still holds access to it. + */ + fileUri?: string; + /** + * Several files to hand over together; `fileUri`, if given, comes first. + * Same source rules as `fileUri`. + */ + fileUris?: string[]; + /** Display name used when a shareable copy has to be created */ + filename?: string; + /** Label for the shared item; defaults to the file name */ + clipLabel?: string; + /** MIME type of the file; resolved from the URI or file name when omitted */ + mimeType?: string; + /** + * Intent action, short (`VIEW`, `EDIT`, `SEND`, `SEND_MULTIPLE`, `RUN`) or + * fully qualified. Defaults to `VIEW` for one file, `SEND_MULTIPLE` for + * several files, and `MAIN` when no file is given. + */ + action?: FileAction | string; + /** Intent categories, e.g. `["LAUNCHER"]`, or fully qualified names */ + categories?: string[]; + /** + * Extra intent flags, as readable names (`NEW_TASK`, `SINGLE_TOP`, + * `CLEAR_TOP`, `GRANT_WRITE_URI_PERMISSION`, …) or raw flag integers. + */ + flags?: Array; + /** Intent extras; arrays arrive as typed arrays on the receiving side */ + extras?: Record< + string, + string | number | boolean | Array + >; + /** Grant the target write access; implied by the `EDIT` action */ + writeAccess?: boolean; + /** Also place the first URI in `Intent.EXTRA_STREAM` */ + attachAsStream?: boolean; + /** Launch the activity in a new task. Defaults to `true` */ + newTask?: boolean; +} + +interface LaunchAppWithFileResult { + /** First content URI handed over, or `''` when no file was passed */ + uri: string; + /** Every content URI handed over, in order */ + uris: string[]; + /** Resolved receiving package, or `''` when it could not be determined */ + app: string; + /** Resolved receiving activity class, or `''` */ + className: string; + /** `package/class` of the resolved receiver, or `''` */ + component: string; + /** Fully qualified action the intent was created with */ + action: string; + /** MIME type the intent was resolved to */ + mimeType: string; +} + interface HttpStreamOptions { method?: string; headers?: Record; @@ -255,6 +326,38 @@ interface System { onFail?: OnFail, ): void; + /** + * Launch an activity and hand it content URIs for one or more files. + * + * Combines `launchApp` (a chosen package and activity, so no system chooser + * is shown) with `fileAction` (a `content://` URI the target may read), and + * is more flexible than either: + * - pass `app` + `className` to target one exact activity, + * - pass only `app` to resolve the best matching activity in that package, + * - pass neither to let the system pick a handler. + * + * Files are passed as the intent data and as `ClipData`, so the target can + * read them through a normal `ContentResolver` stream. + * + * @param options Target, files and optional intent details + * @param onSuccess Called with the content URIs and the resolved component + * @param onFail Called with an error message if nothing could be launched + * + * @example + * system.launchAppWithFile({ + * app: "org.love2d.android", + * className: "org.love2d.android.GameActivity", + * fileUri: "file:///storage/emulated/0/Games/mygame.love", + * mimeType: "application/zip", + * }, (result) => console.log(result.uris, result.component), (err) => + * console.error(err)); + */ + launchAppWithFile( + options: LaunchAppWithFileOptions, + onSuccess?: (result: LaunchAppWithFileResult) => void, + onFail?: OnFail, + ): void; + /** * Opens a link within the app * @param url Url to open diff --git a/src/plugins/system/www/plugin.js b/src/plugins/system/www/plugin.js index e10bc20062..4aec357f9a 100644 --- a/src/plugins/system/www/plugin.js +++ b/src/plugins/system/www/plugin.js @@ -163,6 +163,92 @@ module.exports = { launchApp: function (app, className, extras, onSuccess, onFail) { cordova.exec(onSuccess, onFail, 'System', 'launch-app', [app, className, extras]); }, + /** + * Launch an activity and hand it content URIs for one or more files. + * + * Combines `launchApp` and `fileAction`, and is more flexible than either: + * name the exact activity, name only the package and let the best matching + * activity be resolved, or omit both and let the system pick a handler. + * Files are handed over as `content://` URIs the target may read. Sources are + * `file://` paths (exposed through the app's FileProvider) or existing + * `content://` URIs, which are passed through unchanged and re-granted, so + * they stay readable only while this app still holds access to them. + * + * The default action follows what the call needs: + * - one file -> `VIEW` + * - several files -> `SEND_MULTIPLE` + * - no file -> `MAIN` (a plain app launch) + * + * @param {LaunchAppWithFileOptions} options - Target, files and intent details. + * @param {(result: LaunchAppWithFileResult) => void} [onSuccess] - Receives the content URIs and the resolved component. + * @param {(error: any) => void} [onFail] - Callback invoked if nothing could be launched. + * + * @example Run a .love file in love-android + * System.launchAppWithFile({ + * app: "org.love2d.android", + * className: "org.love2d.android.GameActivity", + * fileUri: "file:///storage/emulated/0/Games/mygame.love", + * mimeType: "application/zip" + * }, (result) => { + * console.log("handed", result.uris, "to", result.component); + * }, (error) => { + * console.error("could not launch love-android", error); + * }); + * + * @example Let the system choose the app + * System.launchAppWithFile({ + * fileUri: "file:///storage/emulated/0/Documents/report.pdf" + * }); + * + * @example Several files, custom flags + * System.launchAppWithFile({ + * app: "com.example.app", + * fileUris: ["file:///a.png", "file:///b.png"], + * mimeType: "image/png", + * flags: ["SINGLE_TOP"] + * }); + */ + launchAppWithFile: function (options, onSuccess, onFail) { + if (typeof onSuccess !== 'function') onSuccess = function () { }; + if (typeof onFail !== 'function') onFail = function () { }; + + if (!options || typeof options !== 'object') { + onFail('System.launchAppWithFile: an options object is required.'); + return; + } + + var app = trimmedString(options.app); + var className = trimmedString(options.className); + var fileUri = trimmedString(options.fileUri); + var fileUris = toUriList(options.fileUris); + + if (className && !app) { + onFail('System.launchAppWithFile: "className" requires "app" (the package name).'); + return; + } + + if (!app && !fileUri && fileUris.length === 0) { + onFail('System.launchAppWithFile: nothing to launch, provide "app" and/or a file.'); + return; + } + + cordova.exec(onSuccess, onFail, 'System', 'launch-app-with-file', [{ + app: app, + className: className, + fileUri: fileUri, + fileUris: fileUris, + filename: trimmedString(options.filename), + clipLabel: trimmedString(options.clipLabel), + mimeType: trimmedString(options.mimeType), + action: trimmedString(options.action), + categories: toNameList(options.categories), + flags: Array.isArray(options.flags) ? options.flags : [], + extras: options.extras && typeof options.extras === 'object' ? options.extras : null, + writeAccess: !!options.writeAccess, + attachAsStream: !!options.attachAsStream, + newTask: options.newTask === undefined ? true : !!options.newTask + }]); + }, inAppBrowser: function (url, title, showButtons, disableCache) { var myInAppBrowser = { onOpenExternalBrowser: null, @@ -509,3 +595,28 @@ function latin1ToBytes(text) { } return bytes; } + +function trimmedString(value) { + if (value === undefined || value === null) return ''; + return String(value).trim(); +} + +function toUriList(value) { + if (!Array.isArray(value)) return []; + const uris = []; + for (const entry of value) { + const uri = trimmedString(entry); + if (uri && !uris.includes(uri)) uris.push(uri); + } + return uris; +} + +function toNameList(value) { + if (!Array.isArray(value)) return []; + const names = []; + for (const entry of value) { + const name = trimmedString(entry); + if (name) names.push(name); + } + return names; +} From 5fd92c7f2254cc744ad5cb72a60103a1834ff064 Mon Sep 17 00:00:00 2001 From: Rohit Kushwaha Date: Fri, 13 Nov 2026 16:46:42 +0530 Subject: [PATCH 2/3] fix: conflicts --- .../android/com/foxdebug/system/System.java | 254 ++++++++++++------ src/plugins/system/system.d.ts | 18 +- src/plugins/system/www/plugin.js | 5 + 3 files changed, 197 insertions(+), 80 deletions(-) diff --git a/src/plugins/system/android/com/foxdebug/system/System.java b/src/plugins/system/android/com/foxdebug/system/System.java index 0a09047cb0..36bd5b08f2 100644 --- a/src/plugins/system/android/com/foxdebug/system/System.java +++ b/src/plugins/system/android/com/foxdebug/system/System.java @@ -1816,6 +1816,7 @@ private void putArrayExtra(Intent intent, String key, JSONArray array) { boolean allBooleans = true; boolean allIntegral = true; boolean allNumbers = true; + boolean needsLong = false; for (int i = 0; i < length; i++) { Object item = array.opt(i); @@ -1825,6 +1826,12 @@ private void putArrayExtra(Intent intent, String key, JSONArray array) { if (!(item instanceof Integer) && !(item instanceof Long)) { allIntegral = false; } + if (item instanceof Long) { + long value = (Long) item; + if (value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) { + needsLong = true; + } + } if (!(item instanceof Number)) { allNumbers = false; } @@ -1840,6 +1847,17 @@ private void putArrayExtra(Intent intent, String key, JSONArray array) { } if (allIntegral) { + // Values outside the 32-bit range must survive as long[], otherwise e.g. + // 4294967296 would silently arrive as 0. + if (needsLong) { + long[] values = new long[length]; + for (int i = 0; i < length; i++) { + values[i] = ((Number) array.opt(i)).longValue(); + } + intent.putExtra(key, values); + return; + } + int[] values = new int[length]; for (int i = 0; i < length; i++) { values[i] = ((Number) array.opt(i)).intValue(); @@ -1919,12 +1937,12 @@ private void launchAppWithFile(JSONObject options, CallbackContext callback) { Uri primary = uris.isEmpty() ? null : uris.get(0); - // A content:// URI owned by another app can be unreadable for us, and - // getType() then throws SecurityException. Resolve the type defensively so - // a revoked or foreign grant cannot abort the launch with a raw exception. + // A multi-file send advertises one type for the whole set, so use the type + // every file shares. A content:// URI from another app can also be + // unreadable here, which makes getType() throw; that must not abort. String mimeType; try { - mimeType = resolveMimeType(requestedMime, primary, filename); + mimeType = resolveCommonMimeType(requestedMime, uris, filename); } catch (RuntimeException error) { Log.w(TAG, "Unable to resolve MIME type, falling back to */*", error); mimeType = "*/*"; @@ -1962,6 +1980,16 @@ private void launchAppWithFile(JSONObject options, CallbackContext callback) { intent.setType(mimeType); } + // A payload-less launch mirrors launchApp: MAIN + LAUNCHER, so the system + // can dispatch to the package's launcher activity. + if ( + primary == null && + requestedAction.isEmpty() && + options.optJSONArray("categories") == null + ) { + intent.addCategory(Intent.CATEGORY_LAUNCHER); + } + applyCategories(intent, options.optJSONArray("categories")); if (!appId.isEmpty()) { @@ -1969,17 +1997,12 @@ private void launchAppWithFile(JSONObject options, CallbackContext callback) { if (!className.isEmpty()) { intent.setClassName(appId, className); - } else if (!resolveComponentInPackage(intent)) { - callback.error( - "No activity in " + - appId + - " handles " + - intentAction + - " for " + - mimeType + - ". Pass \"className\" to target one explicitly." - ); - return; + } else { + // Best effort only. On Android 11+ package visibility can hide an + // installed handler from resolveActivity, and this app declares no + // , while the system can still dispatch to it. So a miss + // here must never block the launch: setPackage alone is enough. + resolveComponentInPackage(intent); } } @@ -2003,12 +2026,13 @@ private void launchAppWithFile(JSONObject options, CallbackContext callback) { return; } - if (primary != null) { - grantUriPermissions(intent, uris, grantFlags); - - if (!appId.isEmpty()) { - grantUriPermissionsToPackage(appId, uris, grantFlags); - } + if (primary != null && !appId.isEmpty()) { + // Grant only the package the caller named. For an implicit launch the + // read/write flag on the intent plus its ClipData is what carries the + // grant, and the system applies it to the receiver the user actually + // picks. Granting to every query result instead would hand read (or + // write) access to apps the user never chooses. + grantUriPermissionsToPackage(appId, uris, grantFlags); } activity.startActivity(intent); @@ -2021,7 +2045,11 @@ private void launchAppWithFile(JSONObject options, CallbackContext callback) { intentAction + " for " + mimeType + - (appId.isEmpty() ? "." : " in " + appId + ".") + (appId.isEmpty() + ? "." + : " in " + + appId + + ". Pass \"className\" to target one explicitly.") : "No activity " + className + " found in package " + appId + "." ); } catch (Exception e) { @@ -2078,14 +2106,16 @@ private List collectFileUris(JSONObject options, String filename) { } /** - * Chooses the activity inside {@code intent}'s package that handles its - * current data and type, falling back to the package's launch activity only - * for a plain MAIN launch. + * Pins the component only when Android lets us see it. This is an + * optimisation that makes the resolved receiver visible in the result; the + * launch itself never depends on it, because package visibility on Android + * 11+ can hide an installed handler from resolveActivity while the system + * can still dispatch to it. * - * Returning false rather than guessing keeps a failed file hand-off from - * silently opening the app's home screen. + * Nothing is guessed here: a miss leaves the intent package-scoped, so a + * failed file hand-off can never silently open the app's home screen. */ - private boolean resolveComponentInPackage(Intent intent) { + private void resolveComponentInPackage(Intent intent) { PackageManager pm = context.getPackageManager(); ResolveInfo resolved = pm.resolveActivity( intent, @@ -2097,27 +2127,7 @@ private boolean resolveComponentInPackage(Intent intent) { resolved.activityInfo.packageName, resolved.activityInfo.name ); - return true; - } - - if ( - Intent.ACTION_MAIN.equals(intent.getAction()) && - intent.getData() == null && - intent.getType() == null - ) { - Intent launch = pm.getLaunchIntentForPackage(intent.getPackage()); - ComponentName component = launch == null ? null : launch.getComponent(); - - if (component != null) { - intent.setClassName( - component.getPackageName(), - component.getClassName() - ); - return true; - } } - - return false; } private void applyCategories(Intent intent, JSONArray categories) { @@ -2289,21 +2299,11 @@ private JSONObject buildLaunchResult( fileUris.put(uri.toString()); } + // Report only a receiver we actually pinned down. For an implicit intent + // with several handlers resolveActivity returns the system's app picker, + // which is not the app the user ends up choosing, so report nothing. ComponentName component = intent.getComponent(); - if (component == null) { - ResolveInfo resolved = context - .getPackageManager() - .resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY); - - if (resolved != null && resolved.activityInfo != null) { - component = new ComponentName( - resolved.activityInfo.packageName, - resolved.activityInfo.name - ); - } - } - JSONObject result = new JSONObject(); result.put("action", action); result.put("mimeType", mimeType); @@ -2878,21 +2878,6 @@ private void grantUriPermissions(Intent intent, Uri uri, int flags) { } } - private void grantUriPermissions(Intent intent, List uris, int flags) { - if (uris == null || uris.isEmpty()) return; - PackageManager pm = context.getPackageManager(); - List resInfoList = pm.queryIntentActivities( - intent, - PackageManager.MATCH_DEFAULT_ONLY - ); - for (ResolveInfo resolveInfo : resInfoList) { - String packageName = resolveInfo.activityInfo.packageName; - for (Uri uri : uris) { - context.grantUriPermission(packageName, uri, flags); - } - } - } - private String resolveMimeType(String currentMime, Uri uri, String filename) { if ( currentMime != null && @@ -2921,6 +2906,123 @@ private String resolveMimeType(String currentMime, Uri uri, String filename) { return (mime != null && !mime.isEmpty()) ? mime : "*/*"; } + /** + * Picks the MIME type to advertise for a whole set of files. + * + * A multi-file send carries a single type, so reusing the first file's type + * would advertise image/png for a PNG+PDF pair and let Android offer + * image-only receivers that cannot open the PDF. The set's common type is + * used instead, widened to a top-level wildcard such as image/* when the + * files share a top-level type, and to the universal wildcard otherwise. + */ + private String resolveCommonMimeType( + String requestedMime, + List uris, + String filename + ) { + if (isConcreteMimeType(requestedMime)) { + return requestedMime; + } + + if (uris == null || uris.isEmpty()) { + return resolveMimeType(requestedMime, null, filename); + } + + if (uris.size() == 1) { + return resolveMimeType(requestedMime, uris.get(0), filename); + } + + String common = null; + + for (int i = 0; i < uris.size(); i++) { + // The display name describes the primary file only. + String type = mimeTypeOf(uris.get(i), i == 0 ? filename : ""); + + if (type == null) { + return "*/*"; + } + + if (common == null) { + common = type; + continue; + } + + if (common.equalsIgnoreCase(type)) { + continue; + } + + String commonTop = topLevelMimeType(common); + String typeTop = topLevelMimeType(type); + + if (commonTop == null || !commonTop.equalsIgnoreCase(typeTop)) { + return "*/*"; + } + + common = commonTop + "/*"; + } + + return common == null ? "*/*" : common; + } + + /** + * Resolves one URI's MIME type without throwing. + * + * @return the type, or null when it cannot be determined. + */ + private String mimeTypeOf(Uri uri, String filenameHint) { + if (uri != null) { + try { + String type = context.getContentResolver().getType(uri); + if (isConcreteMimeType(type)) { + return type; + } + } catch (RuntimeException error) { + Log.w(TAG, "Unable to read MIME type for " + uri, error); + } + } + + if (filenameHint != null && !filenameHint.isEmpty()) { + String type = getMimeTypeFromExtension(filenameHint); + if (isConcreteMimeType(type)) { + return type; + } + } + + if (uri != null) { + String path = uri.getPath(); + if (path != null) { + String type = getMimeTypeFromExtension(path); + if (isConcreteMimeType(type)) { + return type; + } + } + } + + return null; + } + + /** + * Treats catch-all and octet-stream answers as "unknown", otherwise every + * unresolved file would appear to share a type. + */ + private static boolean isConcreteMimeType(String type) { + return ( + type != null && + !type.isEmpty() && + !"*/*".equals(type) && + !"application/octet-stream".equals(type) + ); + } + + private static String topLevelMimeType(String mimeType) { + if (mimeType == null) { + return null; + } + + int slash = mimeType.indexOf('/'); + return slash > 0 ? mimeType.substring(0, slash) : null; + } + private String getFileProviderAuthority() { if (fileProviderAuthority != null && !fileProviderAuthority.isEmpty()) { return fileProviderAuthority; diff --git a/src/plugins/system/system.d.ts b/src/plugins/system/system.d.ts index 4114966a15..cfa51db236 100644 --- a/src/plugins/system/system.d.ts +++ b/src/plugins/system/system.d.ts @@ -101,7 +101,11 @@ interface LaunchAppWithFileOptions { * `CLEAR_TOP`, `GRANT_WRITE_URI_PERMISSION`, …) or raw flag integers. */ flags?: Array; - /** Intent extras; arrays arrive as typed arrays on the receiving side */ + /** + * Intent extras. Arrays arrive as typed arrays on the receiving side; + * integers outside the 32-bit range arrive as a `long[]` rather than being + * truncated. + */ extras?: Record< string, string | number | boolean | Array @@ -119,11 +123,17 @@ interface LaunchAppWithFileResult { uri: string; /** Every content URI handed over, in order */ uris: string[]; - /** Resolved receiving package, or `''` when it could not be determined */ + /** + * Package actually targeted. Empty when the receiver is not pinned down, + * e.g. an implicit launch where the system (or the user) chooses. + */ app: string; - /** Resolved receiving activity class, or `''` */ + /** Targeted activity class, or `''` when the receiver is not pinned down */ className: string; - /** `package/class` of the resolved receiver, or `''` */ + /** + * `package/class` of the target, or `''` when the receiver is not pinned + * down. Never reports the system's app picker as if it were the receiver. + */ component: string; /** Fully qualified action the intent was created with */ action: string; diff --git a/src/plugins/system/www/plugin.js b/src/plugins/system/www/plugin.js index 4aec357f9a..426cdf729a 100644 --- a/src/plugins/system/www/plugin.js +++ b/src/plugins/system/www/plugin.js @@ -174,6 +174,11 @@ module.exports = { * `content://` URIs, which are passed through unchanged and re-granted, so * they stay readable only while this app still holds access to them. * + * When `app` is given, that package is granted access. When it is omitted the + * intent's flag and ClipData carry the grant, so only the app that actually + * receives the files gets access, not every app that could handle them. With + * several files and no `mimeType`, the type the whole set shares is used. + * * The default action follows what the call needs: * - one file -> `VIEW` * - several files -> `SEND_MULTIPLE` From 428825f1e991086c657b3db9f29b5cd82b248593 Mon Sep 17 00:00:00 2001 From: Rohit Kushwaha Date: Fri, 13 Nov 2026 16:54:13 +0530 Subject: [PATCH 3/3] fix: conflicts --- .../android/com/foxdebug/system/System.java | 64 +++++++++++++++---- src/plugins/system/system.d.ts | 7 +- 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/src/plugins/system/android/com/foxdebug/system/System.java b/src/plugins/system/android/com/foxdebug/system/System.java index 36bd5b08f2..5b70487e8a 100644 --- a/src/plugins/system/android/com/foxdebug/system/System.java +++ b/src/plugins/system/android/com/foxdebug/system/System.java @@ -1980,23 +1980,27 @@ private void launchAppWithFile(JSONObject options, CallbackContext callback) { intent.setType(mimeType); } - // A payload-less launch mirrors launchApp: MAIN + LAUNCHER, so the system - // can dispatch to the package's launcher activity. - if ( - primary == null && - requestedAction.isEmpty() && - options.optJSONArray("categories") == null - ) { + // A plain app launch is a launcher launch: MAIN + LAUNCHER. The launcher + // category is implied only when the caller supplied no categories, since + // the binding always sends an array (possibly empty) for "not given". + JSONArray categories = options.optJSONArray("categories"); + boolean hasCategories = categories != null && categories.length() > 0; + boolean launcherLaunch = + primary == null && Intent.ACTION_MAIN.equals(intentAction); + + if (launcherLaunch && !hasCategories) { intent.addCategory(Intent.CATEGORY_LAUNCHER); } - applyCategories(intent, options.optJSONArray("categories")); + applyCategories(intent, categories); if (!appId.isEmpty()) { intent.setPackage(appId); if (!className.isEmpty()) { intent.setClassName(appId, className); + } else if (launcherLaunch && !hasCategories) { + pinLaunchActivity(intent, appId); } else { // Best effort only. On Android 11+ package visibility can hide an // installed handler from resolveActivity, and this app declares no @@ -2105,6 +2109,30 @@ private List collectFileUris(JSONObject options, String filename) { return uris; } + /** + * Pins the launcher activity of {@code appId} for a plain app launch. + * + * A launcher filter normally declares only MAIN and LAUNCHER, so it can never + * match a MATCH_DEFAULT_ONLY lookup. getLaunchIntentForPackage is the + * launcher-specific lookup Android provides for exactly this case. When + * package visibility hides the package it returns null, and the MAIN+LAUNCHER + * intent is still dispatched by the system, so this is an optimisation. + */ + private void pinLaunchActivity(Intent intent, String appId) { + Intent launch = context.getPackageManager().getLaunchIntentForPackage(appId); + ComponentName component = launch == null ? null : launch.getComponent(); + + if (component != null) { + intent.setClassName( + component.getPackageName(), + component.getClassName() + ); + return; + } + + resolveComponentInPackage(intent); + } + /** * Pins the component only when Android lets us see it. This is an * optimisation that makes the resolved receiver visible in the result; the @@ -2920,7 +2948,7 @@ private String resolveCommonMimeType( List uris, String filename ) { - if (isConcreteMimeType(requestedMime)) { + if (isExplicitMimeType(requestedMime)) { return requestedMime; } @@ -3002,8 +3030,22 @@ private String mimeTypeOf(Uri uri, String filenameHint) { } /** - * Treats catch-all and octet-stream answers as "unknown", otherwise every - * unresolved file would appear to share a type. + * True when the caller supplied a real MIME type. + * + * An explicit value always wins, including application/octet-stream, because + * the receiver may accept only that. Only the universal wildcard is treated + * as "caller has no preference". + */ + private static boolean isExplicitMimeType(String mimeType) { + return ( + mimeType != null && !mimeType.isEmpty() && !"*/*".equals(mimeType) + ); + } + + /** + * Treats catch-all and octet-stream answers as "unknown" for types we detect + * ourselves, otherwise every unresolved file would appear to share a type. + * Caller-supplied types are judged by {@link #isExplicitMimeType} instead. */ private static boolean isConcreteMimeType(String type) { return ( diff --git a/src/plugins/system/system.d.ts b/src/plugins/system/system.d.ts index cfa51db236..094d5ee53d 100644 --- a/src/plugins/system/system.d.ts +++ b/src/plugins/system/system.d.ts @@ -86,7 +86,12 @@ interface LaunchAppWithFileOptions { filename?: string; /** Label for the shared item; defaults to the file name */ clipLabel?: string; - /** MIME type of the file; resolved from the URI or file name when omitted */ + /** + * MIME type to advertise. When given it is always honoured, including + * `application/octet-stream`; such a value is never second-guessed by file + * detection. When omitted it is taken from the files, narrowed to the type + * they all share. Pass the universal wildcard to force detection. + */ mimeType?: string; /** * Intent action, short (`VIEW`, `EDIT`, `SEND`, `SEND_MULTIPLE`, `RUN`) or