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 4fcf77316ee177404def0e80f569491305c70277
truex <truex@equwal.com>
2026-09-26 13:59:40 -0700

Let SubRead write the subtitles into a result file

A SubRead job can take hours. Android can stop the reader in that
time, and then the activity result is lost. SubRead 0.10.0 and later
take space.subread.extra.RESULT: a content Uri that they write the .srt
into before they answer. Older versions ignore it, and the activity
result path stays as it is.

makeSubtitles takes an optional resultName. The plugin keeps only
A-Z, a-z, 0-9, ., _ and - of the name and refuses . and ...
The file is files/subread/<name>, shared through the FileProvider. The
call removes the result of an earlier job, puts the Uri in the extra
and in the clip data, and gives SubRead write access to that file only.
FLAG_GRANT_WRITE_URI_PERMISSION on the intent would also ask for write
access to the audio file. The reader can only read that file, so
startActivity would throw SecurityException.

The new method pendingSubtitles reads the file when it is there and
not empty, removes it, and takes back the write access. On the web it
resolves no srt.

 .../java/com/equwal/scansubread/SubReadPlugin.java | 106 ++++++++++++++++++++-
 android/app/src/main/res/xml/file_paths.xml        |   2 +
 .../com/equwal/scansubread/SafeFileNameTest.java   |  36 +++++++
 src/subread.ts                                     |  26 +++++
 test/subread.test.ts                               |   4 +
 5 files changed, 173 insertions(+), 1 deletion(-)
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 5d6e7a6..ec59eee 100644
--- a/android/app/src/main/java/com/equwal/scansubread/SubReadPlugin.java
+++ b/android/app/src/main/java/com/equwal/scansubread/SubReadPlugin.java
@@ -28,6 +28,7 @@ import com.getcapacitor.annotation.ActivityCallback;
 import com.getcapacitor.annotation.CapacitorPlugin;
 import java.io.ByteArrayOutputStream;
 import java.io.File;
+import java.io.FileInputStream;
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
@@ -46,7 +47,8 @@ import java.util.List;
  *   Dictionary, Takoboto, AnkiDroid and other apps in the text selection
  *   menu. The lookup resolves when the dictionary closes.
  * - The subtitle maker, through the intent API of the SubRead app
- *   (`space.subread.app.action.ALIGN`).
+ *   (`space.subread.app.action.ALIGN`). SubRead 0.10.0 and later also
+ *   write the .srt into a result file of this app.
  * - SubRead Anki, through `space.subread.anki.action.ADD`: a card for a
  *   word and its sentence.
  * - The package manager: which apps of the suite are installed. SubRead
@@ -79,9 +81,13 @@ public class SubReadPlugin extends Plugin {
     private static final String EXTRA_CUES = "space.subread.extra.CUES";
     private static final String EXTRA_MATCH_RATE = "space.subread.extra.MATCH_RATE";
     private static final String EXTRA_ERROR = "space.subread.extra.ERROR";
+    private static final String EXTRA_RESULT = "space.subread.extra.RESULT";
 
     private static final String SHARE_DIR = "share";
 
+    /** 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";
     private static final String ACTION_ANKI_ADD = "space.subread.anki.action.ADD";
     private static final String EXTRA_ANKI_WORD = "space.subread.anki.extra.WORD";
@@ -423,15 +429,54 @@ public class SubReadPlugin extends Plugin {
         return ret;
     }
 
+    /**
+     * A safe file name: the name keeps A-Z, a-z, 0-9, ".", "_" and "-", and
+     * each other character becomes "_". So the name cannot point to another
+     * folder. Null when no file can have the name.
+     */
+    static String safeFileName(String name) {
+        String safe = name.replaceAll("[^A-Za-z0-9._-]", "_");
+        if (safe.isEmpty() || safe.equals(".") || safe.equals("..") || safe.length() > 255) return null;
+        return safe;
+    }
+
+    /** The result file for `resultName`: filesDir/subread/<safe name>, or null. */
+    private File resultFile(String resultName) {
+        String name = safeFileName(resultName);
+        return name == null ? null : new File(new File(getContext().getFilesDir(), RESULT_DIR), name);
+    }
+
+    /**
+     * Makes the folder of a result file and removes the result of an earlier
+     * job, so that pendingSubtitles gives only the result of the new job.
+     * Returns the FileProvider Uri of the file.
+     */
+    private Uri clearResult(File file) throws IOException {
+        File dir = file.getParentFile();
+        if (dir != null && !dir.isDirectory() && !dir.mkdirs()) throw new IOException("Cannot create " + dir);
+        if (file.exists() && !file.delete()) throw new IOException("Cannot remove " + file);
+        return FileProvider.getUriForFile(getContext(), getContext().getPackageName() + ".fileprovider", file);
+    }
+
+    /**
+     * Asks SubRead for the .srt of the audio and the book text. The optional
+     * `resultName` names a result file, see pendingSubtitles.
+     */
     @PluginMethod
     public void makeSubtitles(PluginCall call) {
         String audio = call.getString("audio");
         String bookText = call.getString("bookText");
         String language = call.getString("language", "auto");
+        String resultName = call.getString("resultName");
         if (audio == null || bookText == null) {
             call.reject("audio and bookText are required.");
             return;
         }
+        File result = resultName == null ? null : resultFile(resultName);
+        if (resultName != null && result == null) {
+            call.reject("resultName is not a usable file name.");
+            return;
+        }
         Uri bookUri;
         try {
             bookUri = shareFile("book.txt", bookText);
@@ -439,6 +484,15 @@ public class SubReadPlugin extends Plugin {
             call.reject("Cannot write the book text: " + e.getMessage(), e);
             return;
         }
+        Uri resultUri = null;
+        if (result != null) {
+            try {
+                resultUri = clearResult(result);
+            } catch (IOException e) {
+                call.reject("Cannot make the result file: " + e.getMessage(), e);
+                return;
+            }
+        }
         // While SubRead runs, Capacitor keeps the options of this call two
         // times in the saved state of the activity. That state has a limit of
         // about 1 MB. With a large book text, the reader crashes when SubRead
@@ -453,15 +507,65 @@ public class SubReadPlugin extends Plugin {
         // SubRead must be able to read both files.
         ClipData clip = ClipData.newRawUri("audio", audioUri);
         clip.addItem(new ClipData.Item(bookUri));
+        if (resultUri != null) {
+            // SubRead 0.10.0 and later write the .srt into this file before
+            // they answer, so the .srt is safe when Android stops the reader
+            // during the job. Older versions ignore the extra.
+            ask.putExtra(EXTRA_RESULT, resultUri);
+            clip.addItem(new ClipData.Item(resultUri));
+        }
         ask.setClipData(clip);
         ask.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
         if (ask.resolveActivity(getContext().getPackageManager()) == null) {
             call.resolve(errorObject("not_installed"));
             return;
         }
+        if (resultUri != null) {
+            // SubRead must be able to write the result file. The intent flag
+            // FLAG_GRANT_WRITE_URI_PERMISSION asks for write access to each
+            // file of the clip, the audio file too. This app can only read the
+            // audio file, so startActivity would throw SecurityException. So
+            // only the result file gets write access.
+            getContext().grantUriPermission(SUBREAD_PACKAGE, resultUri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+        }
         startActivityForResult(call, ask, "subtitlesResult");
     }
 
+    /**
+     * Gives the .srt that SubRead wrote into the result file `resultName`,
+     * and removes the file. SubRead can finish while Android has stopped the
+     * reader. Then the activity result is lost, but the file is there.
+     * Resolves no srt when the file is not there or is empty.
+     */
+    @PluginMethod
+    public void pendingSubtitles(PluginCall call) {
+        String resultName = call.getString("resultName");
+        if (resultName == null) {
+            call.reject("resultName is required.");
+            return;
+        }
+        File file = resultFile(resultName);
+        if (file == null) {
+            call.reject("resultName is not a usable file name.");
+            return;
+        }
+        JSObject ret = new JSObject();
+        if (file.isFile() && file.length() > 0) {
+            try (InputStream in = new FileInputStream(file)) {
+                ret.put("srt", readAll(in));
+            } catch (IOException e) {
+                call.reject("Cannot read the result file: " + e.getMessage(), e);
+                return;
+            }
+            // The job is done, so SubRead needs no more write access.
+            Uri uri = FileProvider.getUriForFile(getContext(), getContext().getPackageName() + ".fileprovider", file);
+            getContext().revokeUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+            // When the delete fails, the next call gives the same .srt again.
+            file.delete();
+        }
+        call.resolve(ret);
+    }
+
     private static String readAll(InputStream in) throws IOException {
         ByteArrayOutputStream out = new ByteArrayOutputStream();
         byte[] buf = new byte[64 * 1024];
diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml
index d2460ee..5c12152 100644
--- a/android/app/src/main/res/xml/file_paths.xml
+++ b/android/app/src/main/res/xml/file_paths.xml
@@ -4,4 +4,6 @@
     <cache-path name="my_cache_images" path="." />
     <!-- The book text for SubRead and the finished .srt for the share sheet. -->
     <cache-path name="share" path="share/" />
+    <!-- The result files that SubRead writes the .srt into. -->
+    <files-path name="subread" path="subread/" />
 </paths>
diff --git a/android/app/src/test/java/com/equwal/scansubread/SafeFileNameTest.java b/android/app/src/test/java/com/equwal/scansubread/SafeFileNameTest.java
new file mode 100644
index 0000000..1ccd415
--- /dev/null
+++ b/android/app/src/test/java/com/equwal/scansubread/SafeFileNameTest.java
@@ -0,0 +1,36 @@
+package com.equwal.scansubread;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+import org.junit.Test;
+
+/** The name of a result file comes from the web page, so it must stay in its folder. */
+public class SafeFileNameTest {
+
+    @Test
+    public void keepsLettersDigitsDotsLowLinesAndHyphens() {
+        assertEquals("Book_1.2-a.srt", SubReadPlugin.safeFileName("Book_1.2-a.srt"));
+    }
+
+    @Test
+    public void changesEachOtherCharacterToALowLine() {
+        assertEquals("_____.pdf_1234.srt", SubReadPlugin.safeFileName("吾輩は猫だ.pdf|1234.srt"));
+        assertEquals("a_b_c_d", SubReadPlugin.safeFileName("a b/c\\d"));
+    }
+
+    @Test
+    public void cannotPointToAnotherFolder() {
+        assertEquals(".._.._x", SubReadPlugin.safeFileName("../../x"));
+        assertNull(SubReadPlugin.safeFileName(".."));
+        assertNull(SubReadPlugin.safeFileName("."));
+        assertNull(SubReadPlugin.safeFileName(""));
+    }
+
+    @Test
+    public void refusesANameThatIsTooLong() {
+        String longest = new String(new char[255]).replace('\0', 'a');
+        assertEquals(longest, SubReadPlugin.safeFileName(longest));
+        assertNull(SubReadPlugin.safeFileName(longest + "a"));
+    }
+}
diff --git a/src/subread.ts b/src/subread.ts
index 0cb0519..3791d72 100644
--- a/src/subread.ts
+++ b/src/subread.ts
@@ -110,11 +110,34 @@ export interface SubReadPlugin {
    * On the web: rejects with Error('Android only.').
    */
   openOverlay(): Promise<{ opened: 'overlay' | 'settings' }>;
+  /**
+   * Asks SubRead for the .srt of the audio and the book text
+   * (`space.subread.app.action.ALIGN`), and resolves with the answer of
+   * SubRead.
+   *
+   * `resultName` names a result file for the book, for example a name that
+   * the caller makes from the book key. The plugin keeps A-Z, a-z, 0-9, ".",
+   * "_" and "-" of the name, and changes each other character to "_". The
+   * call removes the result of an earlier job with the same name. SubRead
+   * 0.10.0 and later write the .srt into the file before they answer, so
+   * the .srt is safe when Android stops the reader during the job. Older
+   * versions ignore the file. See `pendingSubtitles`.
+   */
   makeSubtitles(options: {
     audio: string;
     bookText: string;
     language: string;
+    resultName?: string;
   }): Promise<SubtitlesResult>;
+  /**
+   * The .srt that SubRead wrote into the result file `resultName` of
+   * `makeSubtitles`, or `{}` when the file is not there or is empty. The
+   * call removes the file after it reads it. Call it when the book opens,
+   * for a job that ended while the reader was stopped. The file stays also
+   * after a normal answer of `makeSubtitles`, so call it then too, and use
+   * one of the two copies. On the web: `{}`.
+   */
+  pendingSubtitles(options: { resultName: string }): Promise<{ srt?: string }>;
   pickAudio(): Promise<{ uri: string; name: string }>;
   shareText(options: { name: string; text: string }): Promise<void>;
 }
@@ -183,6 +206,9 @@ class SubReadWeb implements SubReadPlugin {
   makeSubtitles(): Promise<SubtitlesResult> {
     return Promise.resolve({ error: 'not_installed' });
   }
+  pendingSubtitles(): Promise<{ srt?: string }> {
+    return Promise.resolve({});
+  }
   pickAudio(): Promise<{ uri: string; name: string }> {
     return Promise.reject(new Error('Android only.'));
   }
diff --git a/test/subread.test.ts b/test/subread.test.ts
index babfe04..9a8b340 100644
--- a/test/subread.test.ts
+++ b/test/subread.test.ts
@@ -63,6 +63,10 @@ describe('the web fallback of the plugin', () => {
     });
   });
 
+  it('has no result file of SubRead', async () => {
+    await expect(SubRead.pendingSubtitles({ resultName: 'book.srt' })).resolves.toEqual({});
+  });
+
   it('cannot open SubRead Overlay', async () => {
     await expect(SubRead.openOverlay()).rejects.toThrow('Android only.');
   });