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 c31e762927153b89314c8f410564e108c25923a5
truex <truex@equwal.com>
2026-09-26 18:20:17 -0700

Share the .srt with a clip, and find the debug build of SubRead Anki

On the phone "Share the .srt" logged a SecurityException: the chooser
read the file for its preview without a grant. shareText now puts the
Uri in the clip of the intent. createChooser gives that clip, with the
read grant, to the chooser.

The phone had only the debug build of SubRead Anki
(space.subread.anki.debug), and ankiAdd answered not_installed in
16 ms. ankiAdd and suite now try the release build, then the debug
build, the same as the two authorities of the overlay. The manifest
queries hold the debug package, else Android hides it.
firstInstalled picks the package, and a JVM test covers it.

 android/app/src/main/AndroidManifest.xml           |  1 +
 .../java/com/equwal/scansubread/SubReadPlugin.java | 29 ++++++++++++++---
 .../com/equwal/scansubread/FirstInstalledTest.java | 36 ++++++++++++++++++++++
 src/subread.ts                                     |  7 +++--
 4 files changed, 65 insertions(+), 8 deletions(-)
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 079262f..79cb1ae 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -43,6 +43,7 @@
         <package android:name="space.subread.overlay.debug" />
         <package android:name="space.subread.dictionary" />
         <package android:name="space.subread.anki" />
+        <package android:name="space.subread.anki.debug" />
         <!-- The player provider of SubRead Overlay: the release build and the debug build. -->
         <provider android:authorities="space.subread.overlay.player;space.subread.overlay.debug.player" />
         <!-- The dictionary apps: each app in the text selection menu. -->
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 fae5fdd..8582cc0 100644
--- a/android/app/src/main/java/com/equwal/scansubread/SubReadPlugin.java
+++ b/android/app/src/main/java/com/equwal/scansubread/SubReadPlugin.java
@@ -35,6 +35,7 @@ import java.io.OutputStream;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.function.Predicate;
 
 /**
  * The bridge to the SubRead suite.
@@ -87,7 +88,8 @@ public class SubReadPlugin extends Plugin {
     /** The folder in filesDir for the result files of SubRead. See file_paths.xml. */
     private static final String RESULT_DIR = "subread";
 
-    private static final String ANKI_PACKAGE = "space.subread.anki";
+    /** The release build of SubRead Anki first, then the debug build. */
+    private static final String[] ANKI_PACKAGES = { "space.subread.anki", "space.subread.anki.debug" };
     private static final String ACTION_ANKI_ADD = "space.subread.anki.action.ADD";
     private static final String EXTRA_ANKI_WORD = "space.subread.anki.extra.WORD";
     private static final String EXTRA_ANKI_READING = "space.subread.anki.extra.READING";
@@ -321,10 +323,13 @@ public class SubReadPlugin extends Plugin {
         if (value != null && !value.isEmpty()) intent.putExtra(extra, value);
     }
 
-    /** Asks SubRead Anki to make a card. SubRead Anki needs a word or a text. */
+    /**
+     * Asks SubRead Anki to make a card: the release build, else the debug
+     * build. SubRead Anki needs a word or a text.
+     */
     @PluginMethod
     public void ankiAdd(PluginCall call) {
-        Intent add = new Intent(ACTION_ANKI_ADD).setPackage(ANKI_PACKAGE);
+        Intent add = new Intent(ACTION_ANKI_ADD);
         putText(add, call, "word", EXTRA_ANKI_WORD);
         putText(add, call, "reading", EXTRA_ANKI_READING);
         putText(add, call, "sentence", EXTRA_ANKI_SENTENCE);
@@ -336,10 +341,13 @@ public class SubReadPlugin extends Plugin {
         }
         Boolean show = call.getBoolean("show");
         if (show != null) add.putExtra(EXTRA_ANKI_SHOW, show.booleanValue());
-        if (add.resolveActivity(getContext().getPackageManager()) == null) {
+        PackageManager pm = getContext().getPackageManager();
+        String anki = firstInstalled(ANKI_PACKAGES, name -> new Intent(add).setPackage(name).resolveActivity(pm) != null);
+        if (anki == null) {
             call.resolve(errorObject("not_installed"));
             return;
         }
+        add.setPackage(anki);
         returns.launched();
         if (startForResult(call, add, "ankiResult") != null) call.resolve(errorObject("cannot_start"));
     }
@@ -362,6 +370,12 @@ public class SubReadPlugin extends Plugin {
 
     // --- The suite ---
 
+    /** The first of `packages` that is installed, or null when none is installed. */
+    static String firstInstalled(String[] packages, Predicate<String> installed) {
+        for (String name : packages) if (installed.test(name)) return name;
+        return null;
+    }
+
     /** The package info of an installed app, or null when the app is not installed. */
     private PackageInfo packageInfo(String name) {
         try {
@@ -393,7 +407,7 @@ public class SubReadPlugin extends Plugin {
         ret.put("overlay", packageInfo(OVERLAY_PACKAGE) != null);
         ret.put("overlayDebug", packageInfo(OVERLAY_DEBUG_PACKAGE) != null);
         ret.put("subread", version);
-        ret.put("anki", packageInfo(ANKI_PACKAGE) != null);
+        ret.put("anki", firstInstalled(ANKI_PACKAGES, name -> packageInfo(name) != null) != null);
         ret.put("dictionaries", dictionaries);
         call.resolve(ret);
     }
@@ -681,6 +695,11 @@ public class SubReadPlugin extends Plugin {
                 .setType("text/plain")
                 .putExtra(Intent.EXTRA_STREAM, uri)
                 .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+            // The chooser reads the file for its preview. createChooser gives
+            // the chooser the clip of this intent, with the read grant. Without
+            // a clip the chooser has no grant, and its preview gets a
+            // SecurityException.
+            send.setClipData(ClipData.newRawUri(name, uri));
             getActivity().startActivity(Intent.createChooser(send, name));
             call.resolve();
         } catch (IOException | RuntimeException e) {
diff --git a/android/app/src/test/java/com/equwal/scansubread/FirstInstalledTest.java b/android/app/src/test/java/com/equwal/scansubread/FirstInstalledTest.java
new file mode 100644
index 0000000..b702c21
--- /dev/null
+++ b/android/app/src/test/java/com/equwal/scansubread/FirstInstalledTest.java
@@ -0,0 +1,36 @@
+package com.equwal.scansubread;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+import java.util.Arrays;
+import java.util.List;
+import org.junit.Test;
+
+/** The phone had only the debug build of SubRead Anki, and ankiAdd said not_installed. */
+public class FirstInstalledTest {
+
+    private static final String[] ANKI = { "space.subread.anki", "space.subread.anki.debug" };
+
+    private static String first(String... installed) {
+        List<String> list = Arrays.asList(installed);
+        return SubReadPlugin.firstInstalled(ANKI, list::contains);
+    }
+
+    @Test
+    public void findsTheDebugBuildWhenOnlyItIsInstalled() {
+        assertEquals("space.subread.anki.debug", first("space.subread.anki.debug"));
+    }
+
+    @Test
+    public void prefersTheReleaseBuild() {
+        assertEquals("space.subread.anki", first("space.subread.anki"));
+        assertEquals("space.subread.anki", first("space.subread.anki.debug", "space.subread.anki"));
+    }
+
+    @Test
+    public void givesNullWhenNoBuildIsInstalled() {
+        assertNull(first());
+        assertNull(first("space.subread.app"));
+    }
+}
diff --git a/src/subread.ts b/src/subread.ts
index 92dd2c1..5d8916d 100644
--- a/src/subread.ts
+++ b/src/subread.ts
@@ -64,7 +64,7 @@ export interface SuiteApps {
   overlayDebug: boolean;
   /** The version name of SubRead, for example "0.10.0", or null without SubRead. */
   subread: string | null;
-  /** SubRead Anki. */
+  /** SubRead Anki, the release build or the debug build. */
   anki: boolean;
   /** The number of apps in the text selection menu (the `dictionaries` list), without this app. */
   dictionaries: number;
@@ -93,8 +93,9 @@ export interface SubReadPlugin {
   /** An empty component means: ask each time. */
   setDictionary(options: { component: string }): Promise<void>;
   /**
-   * Makes a card in SubRead Anki (`space.subread.anki.action.ADD`). Only the
-   * fields that are given and not empty go to SubRead Anki; without `word`
+   * Makes a card in SubRead Anki (`space.subread.anki.action.ADD`): the
+   * release build, else the debug build. Only the 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,