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