commit 4ca3d5787a2da32022e128b812243ec908f2ad4d truex <truex@equwal.com> 2026-09-26 13:47:47 -0700 Wait for a dictionary in its own task to close A dictionary whose PROCESS_TEXT activity opens in a new task gives RESULT_CANCELED at once, while it stays on the screen. The lookup then resolved at once, and the reader started the player again while the dictionary was open. ReturnWatch follows handleOnPause and handleOnResume of the plugin. A result that comes after a pause of the reader ends the lookup at once. An immediate result waits for the next resume that follows a pause, that is, until the user is back. When no pause comes in 1500 ms, the dictionary did not open, and the lookup ends. Android pauses and resumes a resumed activity around each result that it gives, in one message of the main thread. ReturnWatch records each pause one message later, so that this pause does not count. JVM tests put the calls in the order that Android gives them.
.../java/com/equwal/scansubread/ReturnWatch.java | 108 +++++++++++++++++ .../java/com/equwal/scansubread/SubReadPlugin.java | 29 ++++- .../com/equwal/scansubread/ReturnWatchTest.java | 130 +++++++++++++++++++++ src/subread.ts | 7 +- 4 files changed, 269 insertions(+), 5 deletions(-)
diff --git a/android/app/src/main/java/com/equwal/scansubread/ReturnWatch.java b/android/app/src/main/java/com/equwal/scansubread/ReturnWatch.java new file mode 100644 index 0000000..55b5d19 --- /dev/null +++ b/android/app/src/main/java/com/equwal/scansubread/ReturnWatch.java @@ -0,0 +1,108 @@ +package com.equwal.scansubread; + +import android.os.Handler; +import android.os.Looper; + +/** + * Tells when an app that the plugin started for a result is closed. + * + * Android gives the activity result when the other app closes. But an app + * that opens in a new task (launchMode singleTask or singleInstance, or + * FLAG_ACTIVITY_NEW_TASK) gives RESULT_CANCELED at once, while it stays on + * the screen. The reader must not start the player again at that time. + * + * The other app covers the reader, so the reader pauses. A result that + * comes after such a pause is final: the watch runs its finish at once. For + * an immediate result, the watch keeps the finish and runs it at the next + * resume that follows a pause: then the user is back. When no pause comes + * in {@link #OPEN_MS}, the other app did not open, and the watch runs the + * finish. + * + * Android also pauses a resumed activity to give it a result, and resumes + * it after. That pause and the result come in one message of the main + * thread. The watch records each pause one message later, so that such a + * pause is not a pause after the launch. + * + * All methods run on the main thread, except {@link #launched()}, which the + * plugin calls on its own thread. + */ +final class ReturnWatch { + + /** Runs code later on the main thread. A test gives its own clock. */ + interface Later { + void run(Runnable code, long delayMs); + } + + /** The time that the other app has to open after an immediate result. */ + static final long OPEN_MS = 1500; + + private final Later later; + + /** True when the activity paused after the last launch. */ + private boolean pausedSinceLaunch; + + /** The finish of a result that came at once. Null when no result waits. */ + private Runnable waiting; + + /** True when the activity paused after the result that waits. */ + private boolean pausedSinceResult; + + /** A watch that runs its later code on the main thread. */ + ReturnWatch() { + this(new Handler(Looper.getMainLooper())::postDelayed); + } + + ReturnWatch(Later later) { + this.later = later; + } + + /** The plugin calls this before it starts an activity for a result. */ + synchronized void launched() { + // A new launch ends the wait for the result before it. + finishWaiting(); + pausedSinceLaunch = false; + } + + /** The plugin calls this from handleOnPause. */ + synchronized void paused() { + if (waiting != null) pausedSinceResult = true; + later.run(this::markPaused, 0); + } + + private synchronized void markPaused() { + pausedSinceLaunch = true; + } + + /** The plugin calls this from handleOnResume. */ + synchronized void resumed() { + if (pausedSinceResult) finishWaiting(); + } + + /** + * The plugin calls this from its activity callback, with the code that + * ends the call. The watch runs `finish` now when the other app is + * closed, else when the user is back. + */ + synchronized void result(Runnable finish) { + finishWaiting(); + if (pausedSinceLaunch) { + finish.run(); + return; + } + waiting = finish; + pausedSinceResult = false; + later.run(() -> notOpened(finish), OPEN_MS); + } + + /** No pause came after the immediate result: the other app did not open. */ + private synchronized void notOpened(Runnable finish) { + if (waiting == finish && !pausedSinceResult) finishWaiting(); + } + + private void finishWaiting() { + Runnable finish = waiting; + waiting = null; + pausedSinceResult = false; + if (finish != null) finish.run(); + } +} 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 d472b4a..0fbe0a6 100644 --- a/android/app/src/main/java/com/equwal/scansubread/SubReadPlugin.java +++ b/android/app/src/main/java/com/equwal/scansubread/SubReadPlugin.java @@ -75,6 +75,19 @@ public class SubReadPlugin extends Plugin { /** The authority that answered last. It is tried first. */ private String authority; + /** Tells when the dictionary is closed. */ + private final ReturnWatch returns = new ReturnWatch(); + + @Override + protected void handleOnPause() { + returns.paused(); + } + + @Override + protected void handleOnResume() { + returns.resumed(); + } + // --- The player, through SubRead Overlay --- private List<String> candidates() { @@ -206,6 +219,7 @@ public class SubReadPlugin extends Plugin { 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) { @@ -213,16 +227,23 @@ public class SubReadPlugin extends Plugin { prefs().edit().remove(PREF_DICTIONARY).apply(); } } + returns.launched(); startActivityForResult(call, Intent.createChooser(send, null), "lookupResult"); } - /** The dictionary closed: the lookup is over. */ + /** + * The dictionary gave its result. The lookup is over when the dictionary + * is closed. A dictionary in a new task gives its result at once, so the + * watch can hold the call until the user is back. + */ @ActivityCallback private void lookupResult(PluginCall call, ActivityResult result) { if (call == null) return; - JSObject ret = new JSObject(); - ret.put("closed", true); - call.resolve(ret); + returns.result(() -> { + JSObject ret = new JSObject(); + ret.put("closed", true); + call.resolve(ret); + }); } @PluginMethod diff --git a/android/app/src/test/java/com/equwal/scansubread/ReturnWatchTest.java b/android/app/src/test/java/com/equwal/scansubread/ReturnWatchTest.java new file mode 100644 index 0000000..dc0c4c0 --- /dev/null +++ b/android/app/src/test/java/com/equwal/scansubread/ReturnWatchTest.java @@ -0,0 +1,130 @@ +package com.equwal.scansubread; + +import static org.junit.Assert.assertEquals; + +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; + +/** + * The order of the pauses, the resumes and the result, as Android gives + * them. Each block of calls with no `main.advance` between them is one + * message of the main thread. + */ +public class ReturnWatchTest { + + /** A main thread for the test: posted code runs when the test moves the clock. */ + private static final class FakeMain implements ReturnWatch.Later { + + private static final class Task { + + final long at; + final Runnable code; + + Task(long at, Runnable code) { + this.at = at; + this.code = code; + } + } + + private final List<Task> tasks = new ArrayList<>(); + private long now; + + @Override + public void run(Runnable code, long delayMs) { + tasks.add(new Task(now + delayMs, code)); + } + + /** Moves the clock and runs the code that is due, the earliest first. */ + void advance(long ms) { + now += ms; + while (true) { + Task next = null; + for (Task t : tasks) if (t.at <= now && (next == null || t.at < next.at)) next = t; + if (next == null) return; + tasks.remove(next); + next.code.run(); + } + } + } + + private final FakeMain main = new FakeMain(); + private final ReturnWatch watch = new ReturnWatch(main); + private int finished; + private final Runnable finish = () -> finished++; + + @Test + public void aResultAfterThePauseForTheOtherAppIsFinal() { + watch.launched(); + // The dictionary covers the reader. + watch.paused(); + main.advance(0); + // The user closes the dictionary. Android gives the result before the resume. + watch.result(finish); + assertEquals(1, finished); + watch.resumed(); + main.advance(ReturnWatch.OPEN_MS); + assertEquals(1, finished); + } + + @Test + public void anImmediateResultWaitsUntilTheUserIsBack() { + watch.launched(); + // The dictionary opens in a new task. Android pauses the reader, gives + // RESULT_CANCELED and resumes the reader, in one message. + watch.paused(); + watch.result(finish); + watch.resumed(); + main.advance(0); + assertEquals(0, finished); + // Then the dictionary covers the reader. + watch.paused(); + main.advance(ReturnWatch.OPEN_MS); + assertEquals(0, finished); + // The user comes back. + watch.resumed(); + assertEquals(1, finished); + main.advance(ReturnWatch.OPEN_MS); + assertEquals(1, finished); + } + + @Test + public void anImmediateResultWithNoPauseAfterItEndsAfterTheOpenTime() { + watch.launched(); + watch.paused(); + watch.result(finish); + watch.resumed(); + main.advance(ReturnWatch.OPEN_MS - 1); + assertEquals(0, finished); + main.advance(1); + assertEquals(1, finished); + } + + @Test + public void aNewLaunchEndsTheWaitOfTheResultBeforeIt() { + watch.launched(); + watch.paused(); + watch.result(finish); + watch.resumed(); + main.advance(0); + watch.launched(); + assertEquals(1, finished); + main.advance(ReturnWatch.OPEN_MS); + assertEquals(1, finished); + } + + @Test + public void aPauseBeforeTheLaunchDoesNotCount() { + watch.paused(); + main.advance(0); + watch.resumed(); + watch.launched(); + watch.paused(); + watch.result(finish); + watch.resumed(); + main.advance(0); + assertEquals(0, finished); + main.advance(ReturnWatch.OPEN_MS); + assertEquals(1, finished); + } +} diff --git a/src/subread.ts b/src/subread.ts index f3b4f9a..89e7884 100644 --- a/src/subread.ts +++ b/src/subread.ts @@ -39,7 +39,12 @@ export interface SubReadPlugin { * a screen wake lock where the browser has one; errors are ignored. */ keepAwake(options: { on: boolean }): Promise<void>; - /** Opens the dictionary with the text. Resolves when the dictionary closes. */ + /** + * 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. + */ lookup(options: { text: string }): Promise<{ closed: boolean }>; dictionaries(): Promise<{ apps: DictionaryApp[]; chosen: string }>; /** An empty component means: ask each time. */