Recently Written · git

scan-subread

PDF reader that follows an audiobook with the SubRead suite

git clone https://github.com/equwal/scan-subread

Log | Files | Refs


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>;
 }