commit fa4b6a367a87e80617d53ca80d7940b53f152557
truex <truex@equwal.com>
2026-09-26 14:33:24 -0700
Stop the app crash when Android cannot start an activity
startActivityForResult and startActivity throw SecurityException or
ActivityNotFoundException, for example for an audio Uri whose read grant
is gone after a restart, or for a dictionary that the user removed.
Capacitor gave the exception to its plugin thread as a RuntimeException,
and the app crashed.
The helper startForResult catches the RuntimeException and gives it to
the caller, which ends the call:
- lookup: the chosen dictionary falls back to the chooser. When the
chooser cannot open, the call rejects.
- makeSubtitles: { error: 'cannot_start: <message>' }. The write grant
of the result file is revoked.
- ankiAdd: { error: 'cannot_start' }.
- pickAudio and openOverlay: the call rejects. shareText already caught
the exception.
A start that fails gives no result, so the return watch has nothing to
end. subread.ts tells the new error values.
.../java/com/equwal/scansubread/SubReadPlugin.java | 49 +++++++++++++++-------
src/subread.ts | 24 +++++++----
2 files changed, 51 insertions(+), 22 deletions(-)
diff --git a/android/app/src/main/java/com/equwal/scansubread/SubReadPlugin.java b/android/app/src/main/java/com/equwal/scansubread/SubReadPlugin.java
index ec59eee..fae5fdd 100644
--- a/android/app/src/main/java/com/equwal/scansubread/SubReadPlugin.java
+++ b/android/app/src/main/java/com/equwal/scansubread/SubReadPlugin.java
@@ -1,7 +1,6 @@
package com.equwal.scansubread;
import android.app.Activity;
-import android.content.ActivityNotFoundException;
import android.content.ClipData;
import android.content.ComponentName;
import android.content.ContentResolver;
@@ -114,6 +113,24 @@ public class SubReadPlugin extends Plugin {
returns.resumed();
}
+ /**
+ * Starts an activity for a result. Returns null when the activity
+ * started, else the exception of Android: ActivityNotFoundException when
+ * no app takes the intent, SecurityException when this app may not start
+ * it, for example when the read grant of an audio Uri is gone after a
+ * restart. Capacitor gives an exception of a plugin method to its plugin
+ * thread, and the app stops. So each caller ends the call with an error.
+ * No result comes, so the return watch has nothing to end.
+ */
+ private RuntimeException startForResult(PluginCall call, Intent intent, String callback) {
+ try {
+ startActivityForResult(call, intent, callback);
+ return null;
+ } catch (RuntimeException e) {
+ return e;
+ }
+ }
+
// --- The player, through SubRead Overlay ---
private List<String> candidates() {
@@ -244,17 +261,14 @@ public class SubReadPlugin extends Plugin {
String chosen = prefs().getString(PREF_DICTIONARY, "");
ComponentName component = chosen.isEmpty() ? null : ComponentName.unflattenFromString(chosen);
if (component != null) {
- try {
- returns.launched();
- startActivityForResult(call, new Intent(send).setComponent(component), "lookupResult");
- return;
- } catch (ActivityNotFoundException e) {
- // The chosen app is gone. Ask.
- prefs().edit().remove(PREF_DICTIONARY).apply();
- }
+ returns.launched();
+ if (startForResult(call, new Intent(send).setComponent(component), "lookupResult") == null) return;
+ // The chosen app is gone, or it does not let this app start it. Ask.
+ prefs().edit().remove(PREF_DICTIONARY).apply();
}
returns.launched();
- startActivityForResult(call, Intent.createChooser(send, null), "lookupResult");
+ RuntimeException failed = startForResult(call, Intent.createChooser(send, null), "lookupResult");
+ if (failed != null) call.reject("Cannot open the dictionary: " + failed.getMessage(), failed);
}
/**
@@ -327,7 +341,7 @@ public class SubReadPlugin extends Plugin {
return;
}
returns.launched();
- startActivityForResult(call, add, "ankiResult");
+ if (startForResult(call, add, "ankiResult") != null) call.resolve(errorObject("cannot_start"));
}
/** SubRead Anki gave its result: the note is in Anki, or the user closed the card. */
@@ -401,7 +415,8 @@ public class SubReadPlugin extends Plugin {
}
try {
getActivity().startActivity(open);
- } catch (ActivityNotFoundException e) {
+ } catch (RuntimeException e) {
+ // ActivityNotFoundException or SecurityException: see startForResult.
call.reject("Cannot open the " + opened + ".", e);
return;
}
@@ -528,7 +543,12 @@ public class SubReadPlugin extends Plugin {
// only the result file gets write access.
getContext().grantUriPermission(SUBREAD_PACKAGE, resultUri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
}
- startActivityForResult(call, ask, "subtitlesResult");
+ RuntimeException failed = startForResult(call, ask, "subtitlesResult");
+ if (failed != null) {
+ // SubRead did not start, so it needs no write access.
+ if (resultUri != null) getContext().revokeUriPermission(resultUri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+ call.resolve(errorObject("cannot_start: " + failed.getMessage()));
+ }
}
/**
@@ -614,7 +634,8 @@ public class SubReadPlugin extends Plugin {
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("audio/*");
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
- startActivityForResult(call, intent, "audioResult");
+ RuntimeException failed = startForResult(call, intent, "audioResult");
+ if (failed != null) call.reject("Cannot open the file chooser: " + failed.getMessage(), failed);
}
@ActivityCallback
diff --git a/src/subread.ts b/src/subread.ts
index 3791d72..92dd2c1 100644
--- a/src/subread.ts
+++ b/src/subread.ts
@@ -24,7 +24,10 @@ export interface SubtitlesResult {
/** 0 to 1: the share of lines whose words were found in the book. */
matchRate?: number;
language?: string | null;
- /** `not_installed`, `cancelled`, or the reason SubRead gave. */
+ /**
+ * `not_installed`, `cancelled`, `cannot_start: <reason>` when Android
+ * cannot start SubRead, or the reason SubRead gave.
+ */
error?: string;
}
@@ -49,8 +52,8 @@ export interface AnkiResult {
added?: boolean;
/** The id of the new note in Anki, when `added` is true. */
noteId?: number;
- /** SubRead Anki is not on the device. */
- error?: 'not_installed';
+ /** SubRead Anki is not on the device, or Android cannot start it. */
+ error?: 'not_installed' | 'cannot_start';
}
/** The apps of the SubRead suite on the device. */
@@ -82,7 +85,8 @@ export interface SubReadPlugin {
* Opens the dictionary with the text. Resolves when the dictionary closes.
* A dictionary that opens in its own task answers at once, so for such an
* answer the call waits until the user is back in the reader, or 1.5 s
- * when the dictionary did not open.
+ * when the dictionary did not open. Rejects when Android cannot open the
+ * dictionary or the chooser.
*/
lookup(options: { text: string }): Promise<{ closed: boolean }>;
dictionaries(): Promise<{ apps: DictionaryApp[]; chosen: string }>;
@@ -93,9 +97,10 @@ export interface SubReadPlugin {
* fields that are given and not empty go to SubRead Anki; without `word`
* and `text` the call rejects. Resolves when SubRead Anki closes, the same
* as `lookup`: `{ added: true, noteId }` when the note is in Anki,
- * `{ added: false }` when the user closed the card, and
- * `{ error: 'not_installed' }` without SubRead Anki. On the web:
- * `{ error: 'not_installed' }`.
+ * `{ added: false }` when the user closed the card,
+ * `{ error: 'not_installed' }` without SubRead Anki, and
+ * `{ error: 'cannot_start' }` when Android cannot start SubRead Anki. On
+ * the web: `{ error: 'not_installed' }`.
*/
ankiAdd(options: AnkiCard): Promise<AnkiResult>;
/**
@@ -107,7 +112,8 @@ export interface SubReadPlugin {
* Opens SubRead Overlay (the release build, else the debug build), so that
* the user can give it notification access. Without SubRead Overlay, opens
* the notification access settings of Android. Resolves which one opened.
- * On the web: rejects with Error('Android only.').
+ * Rejects when Android cannot open it. On the web: rejects with
+ * Error('Android only.').
*/
openOverlay(): Promise<{ opened: 'overlay' | 'settings' }>;
/**
@@ -138,7 +144,9 @@ export interface SubReadPlugin {
* one of the two copies. On the web: `{}`.
*/
pendingSubtitles(options: { resultName: string }): Promise<{ srt?: string }>;
+ /** Rejects when the user picks no file, or when Android cannot open the file chooser. */
pickAudio(): Promise<{ uri: string; name: string }>;
+ /** Rejects when Android cannot open the share sheet. */
shareText(options: { name: string; text: string }): Promise<void>;
}