Recently Written · git

subread-android

SubRead for Android: times an audiobook against its ebook on the device

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

Log | Files | Refs


commit 48015e5da7640d1266e5720cfce430cb3371e37f
equwal <truex@equwal.com>
2026-09-21 13:12:49 -0700

Keep the screen open when the subtitles are ready at once

An ask for an audiobook that SubRead did before is done in less than a
second, because SubRead keeps what it transcribed. The screen opened and
closed at once. In Hoshi Reader this looked like a fault: the user
pressed Make and was back in Hoshi before anything was readable.

When the job is done in less than 3 seconds, the screen now stays, says
that the work was there from an earlier job, and has a button that
returns to the calling app. The answer is the same RESULT_OK. A job
that ran for longer still returns by itself.

AlignIntentTest asks a second time for the same files and checks this.

 .../kotlin/space/subread/app/AlignIntentTest.kt    | 16 ++++++++
 .../main/kotlin/space/subread/app/MainActivity.kt  | 43 +++++++++++++++++++---
 docs/intent-api.md                                 |  5 +++
 3 files changed, 58 insertions(+), 6 deletions(-)
diff --git a/app/src/androidTest/kotlin/space/subread/app/AlignIntentTest.kt b/app/src/androidTest/kotlin/space/subread/app/AlignIntentTest.kt
index 972699b..d7620f3 100644
--- a/app/src/androidTest/kotlin/space/subread/app/AlignIntentTest.kt
+++ b/app/src/androidTest/kotlin/space/subread/app/AlignIntentTest.kt
@@ -8,6 +8,7 @@ import androidx.compose.ui.test.assertIsNotEnabled
 import androidx.compose.ui.test.junit4.createEmptyComposeRule
 import androidx.compose.ui.test.onAllNodesWithText
 import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
 import androidx.core.content.FileProvider
 import androidx.core.net.toUri
 import androidx.lifecycle.Lifecycle
@@ -94,6 +95,21 @@ class AlignIntentTest {
             assertTrue("language $language", language != null && language.matches(Regex("[a-z]{2,3}")))
         }
         assertEquals("the ask must not change what the user picked", picks, savePicks())
+
+        // The same ask again. SubRead has the work of the first job, so the subtitles are ready in
+        // less than a second. On a tablet the screen opened and closed at once, and the user
+        // took it for a fault. Now the screen stays, says why, and has a button for the way back.
+        ActivityScenario.launchActivityForResult<MainActivity>(ask(audio, book, "auto")).use { scenario ->
+            compose.waitUntil(60_000) { Job.status.value.phase == Phase.DONE }
+            compose.waitUntil(10_000) {
+                compose.onAllNodesWithText("ready at once", substring = true).fetchSemanticsNodes().isNotEmpty()
+            }
+            assertTrue("the screen closed by itself", scenario.state != Lifecycle.State.DESTROYED)
+            compose.onNodeWithText("Back to", substring = true).performClick()
+            compose.waitUntil(10_000) { scenario.state == Lifecycle.State.DESTROYED }
+            assertEquals(Activity.RESULT_OK, scenario.result.resultCode)
+            assertNotNull("no Uri in the answer", scenario.result.resultData.data)
+        }
     }
 
     @Test
diff --git a/app/src/main/kotlin/space/subread/app/MainActivity.kt b/app/src/main/kotlin/space/subread/app/MainActivity.kt
index 60f0da4..4e7bf73 100644
--- a/app/src/main/kotlin/space/subread/app/MainActivity.kt
+++ b/app/src/main/kotlin/space/subread/app/MainActivity.kt
@@ -6,6 +6,7 @@ import android.content.pm.PackageManager
 import android.net.Uri
 import android.os.Build
 import android.os.Bundle
+import android.os.SystemClock
 import androidx.activity.ComponentActivity
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.compose.setContent
@@ -72,14 +73,25 @@ class MainActivity : ComponentActivity() {
     /** True when this screen started the job for [ask]. It survives a rotation. */
     private var started = false
 
+    /** When the job for [ask] started, on the clock of SystemClock.elapsedRealtime. */
+    private var takenAt = 0L
+
+    /**
+     * True when the answer is set and the screen stays for the user to read it. A job that
+     * SubRead did before is done in less than a second; to open and close that fast looks
+     * like a fault. It survives a rotation.
+     */
+    private var ready by mutableStateOf(false)
+
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         started = savedInstanceState?.getBoolean(STARTED) == true
+        ready = savedInstanceState?.getBoolean(READY) == true
         val refused = savedInstanceState?.getString(REFUSED)
         // A rotation must not turn a refusal into a second try.
         if (refused != null) refuse(refused) else take(intent)
         val asker = asker()
-        setContent { App(ask, notice, asker, ::answer) }
+        setContent { App(ask, notice, asker, ::answer, ready, ::finish) }
     }
 
     /** The screen is singleTop, so a second ask arrives here, not in a new screen. */
@@ -92,6 +104,7 @@ class MainActivity : ComponentActivity() {
         }
         setIntent(intent)
         started = false
+        ready = false
         notice = null
         take(intent)
     }
@@ -99,6 +112,7 @@ class MainActivity : ComponentActivity() {
     override fun onSaveInstanceState(outState: Bundle) {
         super.onSaveInstanceState(outState)
         outState.putBoolean(STARTED, started)
+        outState.putBoolean(READY, ready)
         (ask as? AlignRequest.Refused)?.let { outState.putString(REFUSED, it.error) }
     }
 
@@ -124,6 +138,7 @@ class MainActivity : ComponentActivity() {
         // rotation the job is already there, so do not start a second one.
         if (!state.running && (!started || state.phase == Phase.IDLE)) {
             started = true
+            takenAt = SystemClock.elapsedRealtime()
             Job.clear()
             val app = applicationContext
             val language = read.language
@@ -163,7 +178,7 @@ class MainActivity : ComponentActivity() {
                 .putExtra(AlignContract.EXTRA_MATCH_RATE, status.matchRate ?: 0.0)
                 .putExtra(AlignContract.EXTRA_LANGUAGE, AlignRequests.languageOf(srt.name, asked)),
         )
-        finish()
+        if (ready || SystemClock.elapsedRealtime() - takenAt < QUICK_ANSWER_MS) ready = true else finish()
     }
 
     /** The name of the app that asked, for the screen. */
@@ -183,6 +198,10 @@ class MainActivity : ComponentActivity() {
     private companion object {
         const val STARTED = "ask_started"
         const val REFUSED = "ask_refused"
+        const val READY = "ask_ready"
+
+        /** A job that is done sooner than this was done before. The screen stays. */
+        const val QUICK_ANSWER_MS = 3_000L
     }
 }
 
@@ -214,6 +233,8 @@ private fun App(
     notice: String? = null,
     asker: String = "",
     onAnswer: (JobStatus, String) -> Unit = { _, _ -> },
+    ready: Boolean = false,
+    onBack: () -> Unit = {},
 ) {
     val context = LocalContext.current
     val prefs = remember { context.getSharedPreferences("picks", Context.MODE_PRIVATE) }
@@ -298,7 +319,8 @@ private fun App(
                     HorizontalDivider(color = Color.Black)
                     Asked(asker, ask, notice,
                         audio?.let { TranscriptStore.describe(context, it).first },
-                        book?.let { TranscriptStore.describe(context, it).first })
+                        book?.let { TranscriptStore.describe(context, it).first },
+                        ready, onBack)
                 }
                 HorizontalDivider(color = Color.Black)
 
@@ -369,7 +391,10 @@ private fun App(
 
 /** Who asked for subtitles, for which files, and what SubRead does with the ask. */
 @Composable
-private fun Asked(asker: String, ask: AlignRequest, notice: String?, audio: String?, book: String?) {
+private fun Asked(
+    asker: String, ask: AlignRequest, notice: String?, audio: String?, book: String?,
+    ready: Boolean, onBack: () -> Unit,
+) {
     Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
         when (ask) {
             is AlignRequest.Accepted -> {
@@ -377,8 +402,14 @@ private fun Asked(asker: String, ask: AlignRequest, notice: String?, audio: Stri
                 Text("Audio: ${audio ?: ask.audio}", style = MaterialTheme.typography.bodySmall)
                 Text("Book: ${book ?: ask.book}", style = MaterialTheme.typography.bodySmall)
                 Text("Language: ${ask.language}", style = MaterialTheme.typography.bodySmall)
-                Text("SubRead sends the .srt back to $asker when the job is done. " +
-                    "Stop, or Back, sends nothing.", style = MaterialTheme.typography.bodySmall)
+                if (ready) {
+                    Text("The subtitles were ready at once: SubRead had the work for this audiobook " +
+                        "from an earlier job.", style = MaterialTheme.typography.bodySmall)
+                    Button(onClick = onBack) { Text("Back to $asker with the .srt") }
+                } else {
+                    Text("SubRead sends the .srt back to $asker when the job is done. " +
+                        "Stop, or Back, sends nothing.", style = MaterialTheme.typography.bodySmall)
+                }
             }
             is AlignRequest.Refused -> {
                 Text("$asker asked for subtitles, and SubRead cannot do it", fontWeight = FontWeight.Bold)
diff --git a/docs/intent-api.md b/docs/intent-api.md
index 1969d64..577f2fe 100644
--- a/docs/intent-api.md
+++ b/docs/intent-api.md
@@ -64,6 +64,11 @@ The job takes about a third of the length of the audio on a mid-range device.
 SubRead keeps what it has transcribed, so asking again for the same audio file
 continues and does not start over.
 
+When the subtitles are ready in less than 3 seconds (SubRead did this audiobook
+before), SubRead does not close by itself. The screen says why the job was so
+fast, and a button returns to the calling app with the same `RESULT_OK` answer.
+A screen that opens and closes at once looks like a fault.
+
 ## The subtitles
 
 SubRip, UTF-8, one cue for each phrase the narrator says, times on the clock of