commit f43ca2ac4ccd6d46d744511eaa8d2afccf835fda equwal <truex@equwal.com> 2026-09-20 23:29:46 -0700 Run the job from the open screen; remove the background service The foreground service of type "special use", the wake lock, and the notification permission are gone. They needed a Google Play policy declaration, and review can reject it. The job now runs on a thread while the screen is open, and the screen keeps the display on for that time. The job saves each finished chunk, so an interrupted job continues from that chunk. The only permission left is usage access, for the optional recent-apps switcher. StartButtonTest proves the new path on a device: Start begins the job, the display lock is on while the job runs and off when it ends.
README.md | 4 +- app/build.gradle.kts | 3 + .../kotlin/space/subread/app/StartButtonTest.kt | 82 +++++++++++++ app/src/main/AndroidManifest.xml | 15 --- .../main/kotlin/space/subread/app/MainActivity.kt | 25 ++-- .../kotlin/space/subread/app/job/AlignService.kt | 136 --------------------- app/src/main/res/values/strings.xml | 2 - gradle/libs.versions.toml | 3 +- tools/write_res.py | 2 - 9 files changed, 104 insertions(+), 168 deletions(-)
diff --git a/README.md b/README.md index 6915255..6629fc6 100644 --- a/README.md +++ b/README.md @@ -63,5 +63,5 @@ gh release create v0.2.0 app/build/outputs/apk/release/app-release.apk --notes " Android 8+, a 64-bit ARM processor with ARMv8.2 half-precision and dot-product instructions (anything from 2018 on). Transcription runs at a small multiple of -real time, so a long book takes hours; it carries on with the screen off and -resumes where it stopped if interrupted. +real time, so a long book takes hours. The job runs only while the app is open +and keeps the screen on. An interrupted job continues from its last finished chunk. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c3fb81b..01a6062 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -85,4 +85,7 @@ dependencies { implementation(libs.kotlinx.coroutines.android) androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/app/src/androidTest/kotlin/space/subread/app/StartButtonTest.kt b/app/src/androidTest/kotlin/space/subread/app/StartButtonTest.kt new file mode 100644 index 0000000..8613872 --- /dev/null +++ b/app/src/androidTest/kotlin/space/subread/app/StartButtonTest.kt @@ -0,0 +1,82 @@ +package space.subread.app + +import android.content.Context +import android.net.Uri +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +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.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import space.subread.app.job.Job +import space.subread.app.job.Phase +import space.subread.app.job.TranscriptStore +import java.io.File + +/** + * The job runs from the open screen. There is no background service. + * Start must begin the job, the display must stay on while the job runs, and + * the display lock must go when the job ends. Uses the same local media as + * OnDeviceJobTest, and skips without it. + */ +@RunWith(AndroidJUnit4::class) +class StartButtonTest { + + @get:Rule + val compose = createEmptyComposeRule() + + @Test + fun startRunsTheJobFromTheScreenAndKeepsTheDisplayOn() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + 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 app = instrumentation.targetContext + fun stage(name: String): Uri { + val out = File(app.cacheDir, name) + instrumentation.context.assets.open("local/$name").use { src -> out.outputStream().use { src.copyTo(it) } } + return Uri.fromFile(out) + } + val audio = stage(audioName!!) + val book = stage(bookName!!) + TranscriptStore.forAudio(app, audio).reset() + // The screen reads its picks from here, as it does after a restart. + app.getSharedPreferences("picks", Context.MODE_PRIVATE).edit() + .putString("audio", audio.toString()).putString("book", book.toString()) + .putString("language", "auto").commit() + + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + fun keepScreenOn(): Boolean { + var on = false + scenario.onActivity { on = it.findViewById<android.view.View>(android.R.id.content).let { root -> + generateSequence(listOf(root)) { level -> + level.flatMap { v -> (v as? android.view.ViewGroup)?.let { g -> (0 until g.childCount).map(g::getChildAt) } ?: emptyList() } + .takeIf { it.isNotEmpty() } + }.flatten().any { it.keepScreenOn } + } } + return on + } + + assertFalse("display lock before the job", keepScreenOn()) + compose.onNodeWithText("Start").performClick() + + compose.waitUntil(30_000) { Job.status.value.running } + compose.waitUntil(10_000) { keepScreenOn() } + + compose.waitUntil(300_000) { !Job.status.value.running } + assertEquals(Job.status.value.detail, Phase.DONE, Job.status.value.phase) + compose.waitUntil(10_000) { !keepScreenOn() } + compose.onNodeWithText("Save .srt").assertExists() + assertTrue((Job.status.value.matchRate ?: 0.0) > 0.8) + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1bce150..0dc2697 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,10 +2,6 @@ <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> - <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> - <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> - <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> - <uses-permission android:name="android.permission.WAKE_LOCK" /> <!-- The optional recent-apps switcher. Not a runtime permission: the user grants it by hand under Settings > Usage access, or not at all. --> <uses-permission android:name="android.permission.PACKAGE_USAGE_STATS" @@ -83,17 +79,6 @@ </intent-filter> </service> - <!-- Special use, not dataSync or mediaProcessing: those are stopped by - the system after six hours, and a long book takes longer. --> - <service - android:name=".job.AlignService" - android:exported="false" - android:foregroundServiceType="specialUse"> - <property - android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" - android:value="On-device speech recognition over a user-selected audiobook, to time it against the user's ebook. Runs for hours and must not be interrupted." /> - </service> - <provider android:name="androidx.core.content.FileProvider" android:authorities="${applicationId}.files" diff --git a/app/src/main/kotlin/space/subread/app/MainActivity.kt b/app/src/main/kotlin/space/subread/app/MainActivity.kt index 8dbc8ab..4db86c6 100644 --- a/app/src/main/kotlin/space/subread/app/MainActivity.kt +++ b/app/src/main/kotlin/space/subread/app/MainActivity.kt @@ -1,10 +1,8 @@ package space.subread.app -import android.Manifest import android.content.Context import android.content.Intent import android.net.Uri -import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.rememberLauncherForActivityResult @@ -32,6 +30,7 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -39,11 +38,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +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.lifecycle.compose.collectAsStateWithLifecycle -import space.subread.app.job.AlignService import space.subread.app.job.Job import space.subread.app.job.JobStatus import space.subread.app.job.Phase @@ -51,6 +50,7 @@ import space.subread.app.job.TranscriptStore import space.subread.app.recents.RecentApps import space.subread.app.recents.RecentsActivity import java.io.File +import kotlin.concurrent.thread class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -113,7 +113,14 @@ private fun App() { context.contentResolver.openOutputStream(dest)?.use { out -> srt.inputStream().use { it.copyTo(out) } } } } - val askNotifications = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {} + // The job runs only while this screen is open. Keep the display on, or the + // system sleeps and stops the work. No background service: the job saves + // each finished chunk, so an interrupted run continues from that chunk. + val view = LocalView.current + DisposableEffect(status.running) { + view.keepScreenOn = status.running + onDispose { view.keepScreenOn = false } + } MaterialTheme(colorScheme = Paper) { Surface(Modifier.fillMaxSize()) { @@ -158,16 +165,14 @@ private fun App() { enabled = a != null && b != null, modifier = Modifier.fillMaxWidth(), onClick = { - if (Build.VERSION.SDK_INT >= 33) { - askNotifications.launch(Manifest.permission.POST_NOTIFICATIONS) - } - AlignService.start(context, a!!, b!!, language) + val app = context.applicationContext + thread(name = "subread-job") { Job.run(app, a!!, b!!, language) } }, ) { Text(if (resumable) "Continue" else "Start") } if (a == null || b == null) { - Text("Pick both files to begin. A long book takes hours; it carries on with the " + - "screen off, and picks up where it stopped if interrupted.", + Text("Pick both files to begin. A long book takes hours. Keep this app open: the " + + "screen stays on while it works. If it is interrupted, Start continues from there.", style = MaterialTheme.typography.bodySmall) } Outcome(status, diff --git a/app/src/main/kotlin/space/subread/app/job/AlignService.kt b/app/src/main/kotlin/space/subread/app/job/AlignService.kt deleted file mode 100644 index 7dc24b1..0000000 --- a/app/src/main/kotlin/space/subread/app/job/AlignService.kt +++ /dev/null @@ -1,136 +0,0 @@ -package space.subread.app.job - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.Service -import android.content.Context -import android.content.Intent -import android.content.pm.ServiceInfo -import android.net.Uri -import android.os.Build -import android.os.IBinder -import android.os.PowerManager -import androidx.core.app.NotificationCompat -import androidx.core.app.ServiceCompat -import androidx.core.content.ContextCompat -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import space.subread.app.MainActivity -import space.subread.app.R -import kotlin.concurrent.thread - -/** - * Keeps the job alive with the screen off and the app in the background. - * - * A foreground service of type "special use": transcription runs for hours, - * and the other candidate types (data sync, media processing) are cut off by - * the system after six. - */ -class AlignService : Service() { - - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - private var wakeLock: PowerManager.WakeLock? = null - - override fun onBind(intent: Intent?): IBinder? = null - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (intent?.action == ACTION_CANCEL) { - Job.cancel() - return START_NOT_STICKY - } - val audio = intent?.getStringExtra(EXTRA_AUDIO) - val book = intent?.getStringExtra(EXTRA_BOOK) - if (audio == null || book == null || Job.status.value.running) { - if (!Job.status.value.running) stopSelf() - return START_NOT_STICKY - } - val language = intent.getStringExtra(EXTRA_LANGUAGE) ?: "auto" - - createChannel() - ServiceCompat.startForeground( - this, NOTIFICATION_ID, notification(Job.status.value), - if (Build.VERSION.SDK_INT >= 34) ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE else 0, - ) - - // Without this the CPU sleeps a few minutes after the screen goes off, - // and a four-hour job becomes a four-day one. - wakeLock = getSystemService(PowerManager::class.java) - .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "subread:align") - .apply { acquire(24 * 60 * 60 * 1000L) } - - scope.launch { - val manager = getSystemService(NotificationManager::class.java) - Job.status.collectLatest { manager.notify(NOTIFICATION_ID, notification(it)) } - } - - thread(name = "subread-job") { - Job.run(applicationContext, Uri.parse(audio), Uri.parse(book), language) - wakeLock?.let { if (it.isHeld) it.release() } - // Leave the final notification up: "done" is worth seeing hours later. - ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH) - stopSelf() - } - return START_NOT_STICKY - } - - override fun onDestroy() { - scope.cancel() - wakeLock?.let { if (it.isHeld) it.release() } - super.onDestroy() - } - - private fun createChannel() { - val channel = NotificationChannel(CHANNEL, getString(R.string.channel_progress), - NotificationManager.IMPORTANCE_LOW) - getSystemService(NotificationManager::class.java).createNotificationChannel(channel) - } - - private fun notification(status: JobStatus): Notification { - val open = PendingIntent.getActivity( - this, 0, Intent(this, MainActivity::class.java), - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - val cancel = PendingIntent.getService( - this, 1, Intent(this, AlignService::class.java).setAction(ACTION_CANCEL), - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - val eta = status.etaSeconds?.let { " ยท about ${Job.clock(it.toDouble())} left" }.orEmpty() - return NotificationCompat.Builder(this, CHANNEL) - .setSmallIcon(R.drawable.ic_notification) - .setContentTitle(getString(R.string.app_name)) - .setContentText(status.detail + eta) - .setContentIntent(open) - .setOnlyAlertOnce(true) - .setOngoing(status.running) - .apply { - if (status.running) { - setProgress(1000, (status.fraction * 1000).toInt(), status.phase == Phase.PREPARING) - addAction(0, getString(R.string.stop), cancel) - } - } - .build() - } - - companion object { - private const val CHANNEL = "progress" - private const val NOTIFICATION_ID = 1 - private const val ACTION_CANCEL = "space.subread.app.CANCEL" - private const val EXTRA_AUDIO = "audio" - private const val EXTRA_BOOK = "book" - private const val EXTRA_LANGUAGE = "language" - - fun start(context: Context, audio: Uri, book: Uri, language: String) { - val intent = Intent(context, AlignService::class.java) - .putExtra(EXTRA_AUDIO, audio.toString()) - .putExtra(EXTRA_BOOK, book.toString()) - .putExtra(EXTRA_LANGUAGE, language) - ContextCompat.startForegroundService(context, intent) - } - } -} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c33ed8e..9d1a3de 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,7 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <resources> <string name="app_name">SubRead</string> - <string name="channel_progress">Progress</string> - <string name="stop">Stop</string> <string name="recents">Recent apps</string> </resources> diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3019534..3d1c486 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,11 +17,12 @@ androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } -androidx-lifecycle-service = { group = "androidx.lifecycle", name = "lifecycle-service", version.ref = "lifecycle" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } diff --git a/tools/write_res.py b/tools/write_res.py index d89e0d3..69a744a 100644 --- a/tools/write_res.py +++ b/tools/write_res.py @@ -20,8 +20,6 @@ w("xml/file_paths.xml", HEAD + """<paths> w("values/strings.xml", HEAD + """<resources> <string name="app_name">SubRead</string> - <string name="channel_progress">Progress</string> - <string name="stop">Stop</string> <string name="recents">Recent apps</string> </resources> """)