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 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>
 """)