commit e690e1b4a03f40e9d46a1d760bcba531fa12732f equwal <truex@equwal.com> 2026-09-21 05:11:26 -0700 Let another app ask SubRead for the subtitles of a book MainActivity takes the ALIGN action of docs/intent-api.md. It reads the two Uris, shows which app asked and for which files, runs the job at once, and sends the .srt back as a content Uri of the FileProvider, with the number of cues, the match rate and the language. A job that fails answers with the error and the screen stays, so the user can read it. The ask gives the picks of that one run. What the user picked by hand stays in the preferences. AlignRequests holds the decisions and uses no Android type, so a JVM test covers them. It refuses an ask with a missing file, with a Uri that is not a content Uri, with a word that is not a language code, or an ask that arrives while a job runs. A file Uri is refused because SubRead would then read its own private files for the caller; the document said the opposite and is corrected.
README.md | 8 + app/build.gradle.kts | 2 + .../kotlin/space/subread/app/AlignIntentTest.kt | 143 +++++++++++++++ app/src/main/AndroidManifest.xml | 6 + .../main/kotlin/space/subread/app/MainActivity.kt | 194 ++++++++++++++++++++- .../space/subread/app/intent/AlignRequest.kt | 81 +++++++++ .../space/subread/app/intent/AlignRequestsTest.kt | 72 ++++++++ docs/intent-api.md | 8 +- 8 files changed, 504 insertions(+), 10 deletions(-)
diff --git a/README.md b/README.md index 2547343..74c22ed 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,14 @@ It is the same method as [subread.space](https://subread.space) and the audio, roughly; the transcript is aligned against the book; the subtitles take their *timing* from the transcript and their *words* from the book. +## Other apps + +A reader or an audiobook player can ask SubRead for the `.srt` of a book and get +the file back: it starts SubRead with `space.subread.app.action.ALIGN`, the two +files as extras, and reads the subtitles from the result. The other app needs no +code of SubRead and no speech model of its own. The contract, with the extras +and the answer, is in [docs/intent-api.md](docs/intent-api.md). + ## Layout | Path | What | diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b14cfc0..6e69e58 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -101,6 +101,8 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.kotlinx.coroutines.android) + // The intent API decides on plain strings, so JUnit alone tests it on a laptop. + testImplementation(libs.junit) androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.test.junit) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/androidTest/kotlin/space/subread/app/AlignIntentTest.kt b/app/src/androidTest/kotlin/space/subread/app/AlignIntentTest.kt new file mode 100644 index 0000000..d751524 --- /dev/null +++ b/app/src/androidTest/kotlin/space/subread/app/AlignIntentTest.kt @@ -0,0 +1,143 @@ +package space.subread.app + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +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.core.content.FileProvider +import androidx.core.net.toUri +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import space.subread.app.intent.AlignContract +import space.subread.app.job.Job +import space.subread.app.job.Phase +import space.subread.app.job.TranscriptStore +import java.io.File + +/** + * Another app asks for the subtitles of a book and gets the file back. + * See docs/intent-api.md. + * + * The media is not in the repository. Put an audio file and its book in + * app/src/androidTest/assets/local/ (gitignored); the test skips without them. + */ +@RunWith(AndroidJUnit4::class) +class AlignIntentTest { + + @get:Rule + val compose = createEmptyComposeRule() + + private val instrumentation get() = InstrumentationRegistry.getInstrumentation() + private val app: Context get() = instrumentation.targetContext + + @Test + fun anotherAppAsksForSubtitlesAndGetsTheFileBack() { + val names = instrumentation.context.assets.list("local").orEmpty() + val audioName = names.firstOrNull { it.endsWith(".mp3") || it.endsWith(".m4b") || it.endsWith(".m4a") } + val bookName = names.firstOrNull { it.endsWith(".epub") || it.endsWith(".txt") } + assumeTrue("no local test media", audioName != null && bookName != null) + + val audio = stage(audioName!!) + val book = stage(bookName!!) + TranscriptStore.forAudio(app, audio).reset() + val picks = savePicks() + + ActivityScenario.launchActivityForResult<MainActivity>(ask(audio, book, "auto")).use { scenario -> + // The job starts by itself, and the screen says who asked for it. + compose.waitUntil(30_000) { Job.status.value.running } + compose.onNodeWithText("asked for subtitles", substring = true).assertExists() + // The files belong to the ask: the user cannot change them under it. + compose.onAllNodesWithText("Change")[0].assertIsNotEnabled() + + compose.waitUntil(1_800_000) { + Job.status.value.phase == Phase.DONE || Job.status.value.phase == Phase.FAILED + } + val result = scenario.result + assertEquals(Job.status.value.detail, Activity.RESULT_OK, result.resultCode) + + val answer = result.resultData + val srt = answer.data + assertNotNull("no Uri in the answer", srt) + assertEquals("content", srt!!.scheme) + assertEquals( + Intent.FLAG_GRANT_READ_URI_PERMISSION, + answer.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + + val text = app.contentResolver.openInputStream(srt)!!.use { it.readBytes().decodeToString() } + assertTrue(text.take(20), text.startsWith("1\n00:00:")) + assertEquals(text.trim().split("\n\n").size, answer.getIntExtra(AlignContract.EXTRA_CUES, 0)) + assertTrue( + "match rate ${answer.getDoubleExtra(AlignContract.EXTRA_MATCH_RATE, 0.0)}", + answer.getDoubleExtra(AlignContract.EXTRA_MATCH_RATE, 0.0) > 0.8, + ) + val language = answer.getStringExtra(AlignContract.EXTRA_LANGUAGE) + assertTrue("language $language", language != null && language.matches(Regex("[a-z]{2,3}"))) + } + assertEquals("the ask must not change what the user picked", picks, savePicks()) + } + + @Test + fun anAskWithoutABookIsRefusedAndNoJobRuns() { + assumeTrue("another job runs", !Job.status.value.running) + val audio = "content://${app.packageName}.files/subtitles/none.m4b".toUri() + val incomplete = Intent(AlignContract.ACTION) + .setClass(app, MainActivity::class.java) + .putExtra(AlignContract.EXTRA_AUDIO, audio) + + ActivityScenario.launchActivityForResult<MainActivity>(incomplete).use { scenario -> + compose.onNodeWithText("cannot do it", substring = true).assertExists() + assertFalse("a job started on a bad ask", Job.status.value.running) + + scenario.onActivity { it.finish() } + val result = scenario.result + assertEquals(Activity.RESULT_CANCELED, result.resultCode) + val error = result.resultData.getStringExtra(AlignContract.EXTRA_ERROR) + assertNotNull("no error in the answer", error) + assertTrue(error!!, error.contains(AlignContract.EXTRA_BOOK)) + } + } + + private fun ask(audio: Uri, book: Uri, language: String) = + Intent(AlignContract.ACTION) + .setClass(app, MainActivity::class.java) + .putExtra(AlignContract.EXTRA_AUDIO, audio) + .putExtra(AlignContract.EXTRA_BOOK, book) + .putExtra(AlignContract.EXTRA_LANGUAGE, language) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + + /** + * A content Uri of the test media, as the Uri of another app would be. + * The files go where the FileProvider serves from (see res/xml/file_paths.xml). + */ + private fun stage(name: String): Uri { + val out = File(app.filesDir, "subtitles/asked").apply { mkdirs() }.resolve(name) + instrumentation.context.assets.open("local/$name").use { src -> + out.outputStream().use { src.copyTo(it) } + } + return FileProvider.getUriForFile(app, "${app.packageName}.files", out) + } + + /** What the user picked by hand. The ask of another app must leave it alone. */ + private fun savePicks(): Map<String, Any?> { + val prefs = app.getSharedPreferences("picks", Context.MODE_PRIVATE) + if (!prefs.contains("audio")) { + prefs.edit().putString("audio", "content://user/audio") + .putString("book", "content://user/book").putString("language", "en").commit() + } + return HashMap(prefs.all) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c3ae19a..c5abb29 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,6 +17,12 @@ <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> + + <!-- Another app asks for the subtitles of a book. See docs/intent-api.md. --> + <intent-filter> + <action android:name="space.subread.app.action.ALIGN" /> + <category android:name="android.intent.category.DEFAULT" /> + </intent-filter> </activity> <provider diff --git a/app/src/main/kotlin/space/subread/app/MainActivity.kt b/app/src/main/kotlin/space/subread/app/MainActivity.kt index 09d1632..60f0da4 100644 --- a/app/src/main/kotlin/space/subread/app/MainActivity.kt +++ b/app/src/main/kotlin/space/subread/app/MainActivity.kt @@ -2,7 +2,9 @@ package space.subread.app import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.rememberLauncherForActivityResult @@ -30,6 +32,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -41,9 +44,13 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.core.content.FileProvider +import androidx.core.content.IntentCompat import androidx.core.content.edit import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle +import space.subread.app.intent.AlignContract +import space.subread.app.intent.AlignRequest +import space.subread.app.intent.AlignRequests import space.subread.app.job.Job import space.subread.app.job.JobStatus import space.subread.app.job.Phase @@ -55,9 +62,127 @@ import java.io.File import kotlin.concurrent.thread class MainActivity : ComponentActivity() { + + /** The ask of another app, when one started this screen. Null on a normal start. */ + private var ask by mutableStateOf<AlignRequest?>(null) + + /** A word to the user about a second ask that arrived while a job runs. */ + private var notice by mutableStateOf<String?>(null) + + /** True when this screen started the job for [ask]. It survives a rotation. */ + private var started = false + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContent { App() } + started = savedInstanceState?.getBoolean(STARTED) == 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) } + } + + /** The screen is singleTop, so a second ask arrives here, not in a new screen. */ + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + if (Job.status.value.running) { + // Refuse politely and keep the job. The job belongs to the ask before this one. + notice = "SubRead is busy with the job it runs now. The new ask was not taken." + return + } + setIntent(intent) + started = false + notice = null + take(intent) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putBoolean(STARTED, started) + (ask as? AlignRequest.Refused)?.let { outState.putString(REFUSED, it.error) } + } + + /** Read the ask of another app and, if it is good, run the job for it. */ + private fun take(from: Intent) { + if (from.action != AlignContract.ACTION) return + val audio = IntentCompat.getParcelableExtra(from, AlignContract.EXTRA_AUDIO, Uri::class.java) + val book = IntentCompat.getParcelableExtra(from, AlignContract.EXTRA_BOOK, Uri::class.java) + val state = Job.status.value + val read = AlignRequests.read( + audio?.toString(), book?.toString(), from.getStringExtra(AlignContract.EXTRA_LANGUAGE), + busy = !started && state.running, + ) + if (read !is AlignRequest.Accepted) { + refuse((read as AlignRequest.Refused).error) + return + } + ask = read + keep(from, audio!!) + keep(from, book!!) + // Start at once: the other app sent the user here for this one job, the + // screen names the asker and the two files, and Stop is on it. After a + // rotation the job is already there, so do not start a second one. + if (!state.running && (!started || state.phase == Phase.IDLE)) { + started = true + Job.clear() + val app = applicationContext + val language = read.language + thread(name = "subread-job") { Job.run(app, audio, book, language) } + } + } + + /** Say no, now, and stay on the screen so that the user can read why. */ + private fun refuse(error: String) { + ask = AlignRequest.Refused(error) + setResult(RESULT_CANCELED, Intent().putExtra(AlignContract.EXTRA_ERROR, error)) + } + + /** Hold the permission when the caller offers it, so that a killed job can start again. */ + private fun keep(from: Intent, uri: Uri) { + if (from.flags and Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION == 0) return + runCatching { + contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } + + /** Give the asking app its answer. [asked] is the language it asked for. */ + private fun answer(status: JobStatus, asked: String) { + val srt = status.srt + if (status.phase != Phase.DONE || srt == null) { + // Back sends this. The screen stays, so the user can read the error. + val error = status.detail.ifEmpty { "The job failed." } + setResult(RESULT_CANCELED, Intent().putExtra(AlignContract.EXTRA_ERROR, error)) + return + } + val uri = FileProvider.getUriForFile(this, "$packageName.files", srt) + setResult( + RESULT_OK, + Intent().setData(uri) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .putExtra(AlignContract.EXTRA_CUES, status.cues) + .putExtra(AlignContract.EXTRA_MATCH_RATE, status.matchRate ?: 0.0) + .putExtra(AlignContract.EXTRA_LANGUAGE, AlignRequests.languageOf(srt.name, asked)), + ) + finish() + } + + /** The name of the app that asked, for the screen. */ + private fun asker(): String { + val who = callingPackage ?: return "Another app" + return runCatching { + val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getApplicationInfo(who, PackageManager.ApplicationInfoFlags.of(0)) + } else { + @Suppress("DEPRECATION") // The flags class is Android 13 and later. + packageManager.getApplicationInfo(who, 0) + } + packageManager.getApplicationLabel(info).toString() + }.getOrDefault(who) + } + + private companion object { + const val STARTED = "ask_started" + const val REFUSED = "ask_refused" } } @@ -84,14 +209,33 @@ private val LANGUAGES = listOf( ) @Composable -private fun App() { +private fun App( + ask: AlignRequest? = null, + notice: String? = null, + asker: String = "", + onAnswer: (JobStatus, String) -> Unit = { _, _ -> }, +) { val context = LocalContext.current val prefs = remember { context.getSharedPreferences("picks", Context.MODE_PRIVATE) } val status by Job.status.collectAsStateWithLifecycle() - var audio by remember { mutableStateOf(prefs.getString("audio", null)?.let(Uri::parse)) } - var book by remember { mutableStateOf(prefs.getString("book", null)?.let(Uri::parse)) } - var language by remember { mutableStateOf(prefs.getString("language", "auto")!!) } + // An ask of another app gives the picks of this run only. What the user + // picked by hand stays in the preferences, untouched. + val accepted = ask as? AlignRequest.Accepted + var audio by remember(accepted) { + mutableStateOf(accepted?.audio?.toUri() ?: prefs.getString("audio", null)?.let(Uri::parse)) + } + var book by remember(accepted) { + mutableStateOf(accepted?.book?.toUri() ?: prefs.getString("book", null)?.let(Uri::parse)) + } + var language by remember(accepted) { + mutableStateOf(accepted?.language ?: prefs.getString("language", "auto")!!) + } + if (accepted != null) { + LaunchedEffect(accepted, status.phase) { + if (status.phase == Phase.DONE || status.phase == Phase.FAILED) onAnswer(status, accepted.language) + } + } fun keep(key: String, uri: Uri) { // So the pick survives the process being killed mid-job and can resume. @@ -149,17 +293,26 @@ private fun App() { "that any video player plays.", style = MaterialTheme.typography.bodyMedium, ) - if (status.phase == Phase.IDLE) Capabilities() + if (status.phase == Phase.IDLE && ask == null) Capabilities() + if (ask != null) { + HorizontalDivider(color = Color.Black) + Asked(asker, ask, notice, + audio?.let { TranscriptStore.describe(context, it).first }, + book?.let { TranscriptStore.describe(context, it).first }) + } HorizontalDivider(color = Color.Black) - Pick("Audiobook", audio?.let { TranscriptStore.describe(context, it).first }, !status.running) { + // The two files of an ask belong to the app that asked. The user + // must not change them under it, so the buttons are off. + val canPick = !status.running && accepted == null + Pick("Audiobook", audio?.let { TranscriptStore.describe(context, it).first }, canPick) { pickAudio.launch(arrayOf("audio/*", "video/mp4", "application/ogg", "application/octet-stream")) } - Pick("Book", book?.let { TranscriptStore.describe(context, it).first }, !status.running) { + Pick("Book", book?.let { TranscriptStore.describe(context, it).first }, canPick) { pickBook.launch(arrayOf("application/epub+zip", "text/plain", "application/zip", "application/octet-stream")) } - LanguagePick(language, !status.running) { + LanguagePick(language, canPick) { language = it prefs.edit().putString("language", it).apply() } @@ -214,6 +367,29 @@ 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?) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + when (ask) { + is AlignRequest.Accepted -> { + Text("$asker asked for subtitles", fontWeight = FontWeight.Bold) + 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) + } + is AlignRequest.Refused -> { + Text("$asker asked for subtitles, and SubRead cannot do it", fontWeight = FontWeight.Bold) + Text(ask.error, style = MaterialTheme.typography.bodySmall) + Text("Back returns to $asker with this error.", style = MaterialTheme.typography.bodySmall) + } + } + if (notice != null) Text(notice, style = MaterialTheme.typography.bodySmall) + } +} + @Composable private fun Capabilities() { val named = LANGUAGES.drop(1).take(5).joinToString(", ") { it.second } diff --git a/app/src/main/kotlin/space/subread/app/intent/AlignRequest.kt b/app/src/main/kotlin/space/subread/app/intent/AlignRequest.kt new file mode 100644 index 0000000..1e00a39 --- /dev/null +++ b/app/src/main/kotlin/space/subread/app/intent/AlignRequest.kt @@ -0,0 +1,81 @@ +package space.subread.app.intent + +/** The names another app uses to ask for subtitles. See docs/intent-api.md. */ +object AlignContract { + const val ACTION = "space.subread.app.action.ALIGN" + const val EXTRA_AUDIO = "space.subread.extra.AUDIO" + const val EXTRA_BOOK = "space.subread.extra.BOOK" + + /** The language to use, in the ask; the language that was used, in the answer. */ + const val EXTRA_LANGUAGE = "space.subread.extra.LANGUAGE" + const val EXTRA_CUES = "space.subread.extra.CUES" + const val EXTRA_MATCH_RATE = "space.subread.extra.MATCH_RATE" + const val EXTRA_ERROR = "space.subread.extra.ERROR" +} + +/** What the screen does with the ask of another app. */ +sealed interface AlignRequest { + /** The ask is good. Run the job on these two files. */ + data class Accepted(val audio: String, val book: String, val language: String) : AlignRequest + + /** SubRead cannot do the ask. [error] goes on the screen and back to the caller. */ + data class Refused(val error: String) : AlignRequest +} + +/** + * The decisions of the intent API, with no Android type in them, so that a JVM + * test covers them without a device. + */ +object AlignRequests { + /** A Whisper language code is two or three lower-case letters, as in "ja" or "yue". */ + private val CODE = Regex("[a-z]{2,3}") + + /** The default language: the speech model listens and decides. */ + const val AUTO = "auto" + + /** + * Read the ask. [audio] and [book] are the Uris of the two extras, as text. + * [busy] is true when a job already runs, because SubRead runs one job at a + * time and must not stop that job for a new ask. + */ + fun read(audio: String?, book: String?, language: String?, busy: Boolean): AlignRequest { + if (busy) { + return AlignRequest.Refused( + "SubRead is busy with another job. Ask again when that job is done.", + ) + } + val code = language?.trim().orEmpty().ifEmpty { AUTO } + val problem = badUri("audio file", AlignContract.EXTRA_AUDIO, audio) + ?: badUri("book", AlignContract.EXTRA_BOOK, book) + ?: badLanguage(code) + return if (problem != null) AlignRequest.Refused(problem) + else AlignRequest.Accepted(audio!!.trim(), book!!.trim(), code) + } + + /** + * The language the job used, read from the name of the file it wrote + * ("book.ja.srt" gives "ja"). [asked] is the answer when the name holds no + * code, which happens only if the naming changes. + */ + fun languageOf(srtName: String, asked: String): String { + val code = srtName.removeSuffix(".srt").substringAfterLast('.', "") + return if (CODE.matches(code)) code else asked + } + + private fun badUri(what: String, extra: String, uri: String?): String? { + val text = uri?.trim().orEmpty() + return when { + text.isEmpty() -> "The ask gives no $what. Put its Uri in $extra." + // Only a content Uri is granted by the caller. A file Uri would make + // SubRead read its own private files for a stranger. + !text.startsWith("content://") -> + "The $what must be a content:// Uri that the asking app grants, not $text." + + else -> null + } + } + + private fun badLanguage(code: String): String? = + if (code == AUTO || CODE.matches(code)) null + else "$code is not a language. Use a Whisper code such as ja, or $AUTO." +} diff --git a/app/src/test/kotlin/space/subread/app/intent/AlignRequestsTest.kt b/app/src/test/kotlin/space/subread/app/intent/AlignRequestsTest.kt new file mode 100644 index 0000000..b723cbb --- /dev/null +++ b/app/src/test/kotlin/space/subread/app/intent/AlignRequestsTest.kt @@ -0,0 +1,72 @@ +package space.subread.app.intent + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The decisions of the intent API. No Android, so this runs on a laptop. */ +class AlignRequestsTest { + + private val audio = "content://other.app.files/audio/book.m4b" + private val book = "content://other.app.files/books/book.epub" + + private fun refusal(ask: AlignRequest): String { + assertTrue("expected a refusal, got $ask", ask is AlignRequest.Refused) + return (ask as AlignRequest.Refused).error + } + + @Test + fun aGoodAskIsAccepted() { + val ask = AlignRequests.read(audio, book, "ja", busy = false) + assertEquals(AlignRequest.Accepted(audio, book, "ja"), ask) + } + + @Test + fun noLanguageMeansAuto() { + assertEquals(AlignRequest.Accepted(audio, book, "auto"), AlignRequests.read(audio, book, null, false)) + assertEquals(AlignRequest.Accepted(audio, book, "auto"), AlignRequests.read(audio, book, " ", false)) + assertEquals(AlignRequest.Accepted(audio, book, "auto"), AlignRequests.read(audio, book, "auto", false)) + } + + @Test + fun aThreeLetterCodeIsALanguageToo() { + assertEquals(AlignRequest.Accepted(audio, book, "yue"), AlignRequests.read(audio, book, "yue", false)) + } + + @Test + fun aMissingFileIsRefusedAndTheErrorNamesTheExtra() { + assertTrue(refusal(AlignRequests.read(null, book, "ja", false)).contains(AlignContract.EXTRA_AUDIO)) + assertTrue(refusal(AlignRequests.read("", book, "ja", false)).contains(AlignContract.EXTRA_AUDIO)) + assertTrue(refusal(AlignRequests.read(audio, null, "ja", false)).contains(AlignContract.EXTRA_BOOK)) + } + + @Test + fun onlyAContentUriIsTaken() { + // A file Uri would make SubRead read its own private files for a stranger. + assertTrue(refusal(AlignRequests.read("file:///data/data/space.subread.app/x", book, "ja", false)) + .contains("content://")) + assertTrue(refusal(AlignRequests.read(audio, "/sdcard/book.epub", "ja", false)).contains("content://")) + } + + @Test + fun aWordThatIsNotALanguageCodeIsRefused() { + assertTrue(refusal(AlignRequests.read(audio, book, "Japanese", false)).contains("Japanese")) + assertTrue(refusal(AlignRequests.read(audio, book, "j", false)).contains("Whisper")) + } + + @Test + fun anAskDuringAJobIsRefused() { + val error = refusal(AlignRequests.read(audio, book, "ja", busy = true)) + assertTrue(error, error.contains("busy")) + } + + @Test + fun theLanguageOfTheAnswerComesFromTheNameOfTheFile() { + assertEquals("ja", AlignRequests.languageOf("book.ja.srt", "auto")) + assertEquals("ru", AlignRequests.languageOf("part 1. the start.ru.srt", "auto")) + assertEquals("yue", AlignRequests.languageOf("book.yue.srt", "auto")) + // No code in the name: say what was asked for. + assertEquals("ja", AlignRequests.languageOf("book.srt", "ja")) + assertEquals("auto", AlignRequests.languageOf("book.subtitles.srt", "auto")) + } +} diff --git a/docs/intent-api.md b/docs/intent-api.md index 49ab55d..1969d64 100644 --- a/docs/intent-api.md +++ b/docs/intent-api.md @@ -22,7 +22,8 @@ startActivityForResult(ask, REQUEST_SUBTITLES) // or an ActivityResultLau Both Uris must be `content://` Uris that the calling app may grant (its own `FileProvider`, or a document Uri for which it holds a persisted permission). -A `file://` Uri works only for a file that SubRead can read, so do not use one. +SubRead refuses every other Uri, a `file://` Uri included: SubRead would read +its own private files for the caller. One audio file for one book. A book in many audio files is not handled yet. @@ -54,6 +55,11 @@ and Google Play when those listings are up). `RESULT_CANCELED`: the user went back, or the job failed. `space.subread.extra.ERROR` (`String`) says why when it failed. +SubRead makes one set of subtitles at a time. An ask that arrives while a job +runs is refused with `RESULT_CANCELED` and an `ERROR`; the job that runs is not +touched. An ask with a file that is missing or is not a `content://` Uri is +refused the same way, before any work starts. + 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.