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 3c439b08848fbc9bfe12e481443af15bc2309860
equwal <truex@equwal.com>
2026-09-21 00:01:05 -0700

Remove the recent-apps switcher

The switcher does not belong to this product. This change deletes its
activity, quick-settings tile, launcher alias, setting, string and icon,
and the usage-access permission and package queries it needed. The app
now asks for no permission.

The cancel code from the native bridge is now 1000. whisper_full uses
small negative numbers for its own errors, and -1 could be read as a
cancel. Job.cancel logs its caller: two device test runs stopped with
no visible cause while a second tool drove the same tablet, and the
log will show the source if it occurs again.

 app/src/main/AndroidManifest.xml                   |  62 +-------
 app/src/main/cpp/whisper_jni.c                     |   4 +-
 .../main/kotlin/space/subread/app/MainActivity.kt  |  34 -----
 app/src/main/kotlin/space/subread/app/job/Job.kt   |   2 +
 .../kotlin/space/subread/app/recents/RecentApps.kt | 100 -------------
 .../space/subread/app/recents/RecentsActivity.kt   | 164 ---------------------
 .../kotlin/space/subread/app/whisper/Whisper.kt    |   2 +-
 app/src/main/res/drawable/ic_notification.xml      |   6 -
 app/src/main/res/values/strings.xml                |   1 -
 tools/write_res.py                                 |   2 -
 10 files changed, 6 insertions(+), 371 deletions(-)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0dc2697..c3ae19a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,19 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?>
-<manifest xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:tools="http://schemas.android.com/tools">
-
-    <!-- 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"
-        tools:ignore="ProtectedPermissions" />
-
-    <!-- Needed since Android 11 to see other apps' names, icons and launch intents. -->
-    <queries>
-        <intent>
-            <action android:name="android.intent.action.MAIN" />
-            <category android:name="android.intent.category.LAUNCHER" />
-        </intent>
-    </queries>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
 
     <application
         android:allowBackup="false"
@@ -33,52 +19,6 @@
             </intent-filter>
         </activity>
 
-        <!-- The recent-apps switcher. Reachable three ways once switched on in
-             the app: its own launcher icon, a quick-settings tile, and - for
-             anything that maps a hardware key or gesture to an intent - the
-             action space.subread.app.RECENTS (or as the device's assist app). -->
-        <activity
-            android:name=".recents.RecentsActivity"
-            android:exported="true"
-            android:excludeFromRecents="true"
-            android:launchMode="singleInstance"
-            android:noHistory="true"
-            android:taskAffinity="space.subread.app.recents"
-            android:theme="@style/Theme.SubRead">
-            <intent-filter>
-                <action android:name="space.subread.app.RECENTS" />
-                <category android:name="android.intent.category.DEFAULT" />
-            </intent-filter>
-            <intent-filter>
-                <action android:name="android.intent.action.ASSIST" />
-                <category android:name="android.intent.category.DEFAULT" />
-            </intent-filter>
-        </activity>
-
-        <activity-alias
-            android:name=".recents.RecentsLauncherAlias"
-            android:enabled="false"
-            android:exported="true"
-            android:label="@string/recents"
-            android:targetActivity=".recents.RecentsActivity">
-            <intent-filter>
-                <action android:name="android.intent.action.MAIN" />
-                <category android:name="android.intent.category.LAUNCHER" />
-            </intent-filter>
-        </activity-alias>
-
-        <service
-            android:name=".recents.RecentsTile"
-            android:enabled="false"
-            android:exported="true"
-            android:icon="@drawable/ic_notification"
-            android:label="@string/recents"
-            android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
-            <intent-filter>
-                <action android:name="android.service.quicksettings.action.QS_TILE" />
-            </intent-filter>
-        </service>
-
         <provider
             android:name="androidx.core.content.FileProvider"
             android:authorities="${applicationId}.files"
diff --git a/app/src/main/cpp/whisper_jni.c b/app/src/main/cpp/whisper_jni.c
index 7416e0c..4e23076 100644
--- a/app/src/main/cpp/whisper_jni.c
+++ b/app/src/main/cpp/whisper_jni.c
@@ -51,7 +51,7 @@ JNI_FN(freeContext)(JNIEnv *env, jobject thiz, jlong ptr) {
     if (ptr != 0) whisper_free((struct whisper_context *) ptr);
 }
 
-// Returns 0 on success, -1 if cancelled, anything else is whisper's own error.
+// Returns 0 on success, 1000 if cancelled, anything else is whisper's own error.
 JNIEXPORT jint JNICALL
 JNI_FN(transcribe)(JNIEnv *env, jobject thiz, jlong ptr, jfloatArray samples,
                    jint n_threads, jstring language) {
@@ -88,7 +88,7 @@ JNI_FN(transcribe)(JNIEnv *env, jobject thiz, jlong ptr, jfloatArray samples,
     (*env)->ReleaseStringUTFChars(env, language, lang);
     (*env)->ReleaseFloatArrayElements(env, samples, pcm, JNI_ABORT);
 
-    if (atomic_load(&g_abort)) return -1;
+    if (atomic_load(&g_abort)) return 1000;
     if (rc != 0) LOGW("whisper_full failed: %d", rc);
     return rc;
 }
diff --git a/app/src/main/kotlin/space/subread/app/MainActivity.kt b/app/src/main/kotlin/space/subread/app/MainActivity.kt
index 4db86c6..01360aa 100644
--- a/app/src/main/kotlin/space/subread/app/MainActivity.kt
+++ b/app/src/main/kotlin/space/subread/app/MainActivity.kt
@@ -26,7 +26,6 @@ import androidx.compose.material3.LinearProgressIndicator
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.OutlinedButton
 import androidx.compose.material3.Surface
-import androidx.compose.material3.Switch
 import androidx.compose.material3.Text
 import androidx.compose.material3.lightColorScheme
 import androidx.compose.runtime.Composable
@@ -47,8 +46,6 @@ import space.subread.app.job.Job
 import space.subread.app.job.JobStatus
 import space.subread.app.job.Phase
 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
 
@@ -180,37 +177,6 @@ private fun App() {
                         onShare = { status.srt?.let { share(context, it) } })
                 }
 
-                HorizontalDivider(color = Color.Black)
-                RecentsSetting()
-            }
-        }
-    }
-}
-
-/** Nothing to do with subtitles: an optional recent-apps switcher, off unless asked for. */
-@Composable
-private fun RecentsSetting() {
-    val context = LocalContext.current
-    var on by remember { mutableStateOf(RecentApps.isEnabled(context)) }
-    Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
-        Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
-            Switch(checked = on, onCheckedChange = {
-                on = it
-                RecentApps.setEnabled(context, it)
-                if (it && !RecentApps.hasAccess(context)) {
-                    context.startActivity(Intent(android.provider.Settings.ACTION_USAGE_ACCESS_SETTINGS))
-                }
-            })
-            Text("Recent apps switcher", fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 12.dp))
-        }
-        Text(
-            "Adds a \"Recent apps\" icon and a quick-settings tile that show your recent apps as cards to " +
-                "swipe through. It needs usage access, which you grant on the screen that opens.",
-            style = MaterialTheme.typography.bodySmall,
-        )
-        if (on) {
-            OutlinedButton(onClick = { context.startActivity(Intent(context, RecentsActivity::class.java)) }) {
-                Text("Open it now")
             }
         }
     }
diff --git a/app/src/main/kotlin/space/subread/app/job/Job.kt b/app/src/main/kotlin/space/subread/app/job/Job.kt
index b088bfc..1e4a68b 100644
--- a/app/src/main/kotlin/space/subread/app/job/Job.kt
+++ b/app/src/main/kotlin/space/subread/app/job/Job.kt
@@ -40,6 +40,8 @@ object Job {
     @Volatile private var cancelled = false
 
     fun cancel() {
+        // The stack shows who asked. A stop with no visible cause is hard to find without it.
+        android.util.Log.i("SubRead", "stop requested", Throwable())
         cancelled = true
         Whisper.cancel()
     }
diff --git a/app/src/main/kotlin/space/subread/app/recents/RecentApps.kt b/app/src/main/kotlin/space/subread/app/recents/RecentApps.kt
deleted file mode 100644
index 8675eaf..0000000
--- a/app/src/main/kotlin/space/subread/app/recents/RecentApps.kt
+++ /dev/null
@@ -1,100 +0,0 @@
-package space.subread.app.recents
-
-import android.app.AppOpsManager
-import android.app.usage.UsageEvents
-import android.app.usage.UsageStatsManager
-import android.content.ComponentName
-import android.content.Context
-import android.content.Intent
-import android.content.pm.PackageManager
-import android.graphics.drawable.Drawable
-import android.os.Process
-
-/** One app in the switcher. */
-class RecentApp(
-    val packageName: String,
-    val label: String,
-    val icon: Drawable,
-    val lastUsed: Long,
-    val launch: Intent,
-)
-
-/**
- * What an ordinary app can know about "recent apps".
- *
- * The real recents screen belongs to the system launcher: only it may see live
- * task thumbnails or swipe a task away. What is open to anyone the user grants
- * usage access to is the *order* apps were last in the foreground - which is
- * the part that matters for switching. So: no screenshots, no kill; icon,
- * name, tap to go back to it.
- */
-object RecentApps {
-
-    fun hasAccess(context: Context): Boolean {
-        val ops = context.getSystemService(AppOpsManager::class.java)
-        val mode = ops.unsafeCheckOpNoThrow(
-            AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName)
-        return mode == AppOpsManager.MODE_ALLOWED
-    }
-
-    /** Most recently used first. Skips this app, home screens and anything with no launcher entry. */
-    fun list(context: Context, limit: Int = 24): List<RecentApp> {
-        val pm = context.packageManager
-        val usage = context.getSystemService(UsageStatsManager::class.java)
-        val now = System.currentTimeMillis()
-
-        // Foreground events give the true order; the daily aggregates only
-        // say "some time today". Look back further only if the day is empty.
-        val lastSeen = LinkedHashMap<String, Long>()
-        for (window in longArrayOf(DAY, 7 * DAY)) {
-            val events = usage.queryEvents(now - window, now)
-            val e = UsageEvents.Event()
-            while (events.hasNextEvent()) {
-                events.getNextEvent(e)
-                if (e.eventType == UsageEvents.Event.ACTIVITY_RESUMED) lastSeen[e.packageName] = e.timeStamp
-            }
-            if (lastSeen.size > 2) break
-        }
-
-        val homes = pm.queryIntentActivities(
-            Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME), 0
-        ).map { it.activityInfo.packageName }.toSet()
-
-        return lastSeen.entries
-            .sortedByDescending { it.value }
-            .asSequence()
-            .filter { it.key != context.packageName && it.key !in homes }
-            .mapNotNull { (pkg, at) ->
-                val launch = pm.getLaunchIntentForPackage(pkg) ?: return@mapNotNull null
-                val info = runCatching { pm.getApplicationInfo(pkg, 0) }.getOrNull() ?: return@mapNotNull null
-                // Bring the existing task forward as it was, rather than restarting the app.
-                launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
-                RecentApp(pkg, pm.getApplicationLabel(info).toString(), pm.getApplicationIcon(info), at, launch)
-            }
-            .take(limit)
-            .toList()
-    }
-
-    // ------------------------------------------------------------ on / off
-
-    private val entryPoints = listOf(".recents.RecentsLauncherAlias", ".recents.RecentsTile")
-
-    /**
-     * The switcher's launcher icon and quick-settings tile exist only while it
-     * is switched on. Off, the app is exactly the subtitle tool and nothing else.
-     */
-    fun setEnabled(context: Context, on: Boolean) {
-        val state = if (on) PackageManager.COMPONENT_ENABLED_STATE_ENABLED
-        else PackageManager.COMPONENT_ENABLED_STATE_DISABLED
-        for (name in entryPoints) {
-            context.packageManager.setComponentEnabledSetting(
-                ComponentName(context.packageName, "space.subread.app$name"), state, PackageManager.DONT_KILL_APP)
-        }
-        context.getSharedPreferences("picks", Context.MODE_PRIVATE).edit().putBoolean("recents", on).apply()
-    }
-
-    fun isEnabled(context: Context): Boolean =
-        context.getSharedPreferences("picks", Context.MODE_PRIVATE).getBoolean("recents", false)
-
-    private const val DAY = 24 * 60 * 60 * 1000L
-}
diff --git a/app/src/main/kotlin/space/subread/app/recents/RecentsActivity.kt b/app/src/main/kotlin/space/subread/app/recents/RecentsActivity.kt
deleted file mode 100644
index ddc0796..0000000
--- a/app/src/main/kotlin/space/subread/app/recents/RecentsActivity.kt
+++ /dev/null
@@ -1,164 +0,0 @@
-package space.subread.app.recents
-
-import android.content.Intent
-import android.net.Uri
-import android.os.Bundle
-import android.provider.Settings
-import android.service.quicksettings.TileService
-import androidx.activity.ComponentActivity
-import androidx.activity.compose.setContent
-import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.border
-import androidx.compose.foundation.combinedClickable
-import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.safeDrawingPadding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.lazy.LazyRow
-import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Button
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.material3.lightColorScheme
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.asImageBitmap
-import androidx.compose.ui.platform.LocalConfiguration
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-import androidx.core.graphics.drawable.toBitmap
-
-/**
- * A recent-apps switcher in the stock Android manner: a row of cards, most
- * recent first, that slides sideways and settles on one. Tap to go there,
- * long-press for the app's settings page.
- */
-class RecentsActivity : ComponentActivity() {
-
-    private var apps by mutableStateOf<List<RecentApp>>(emptyList())
-    private var allowed by mutableStateOf(true)
-
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-        setContent { Screen() }
-    }
-
-    override fun onResume() {
-        super.onResume()
-        allowed = RecentApps.hasAccess(this)
-        apps = if (allowed) RecentApps.list(this) else emptyList()
-    }
-
-    private fun open(app: RecentApp) {
-        startActivity(app.launch)
-        finish()
-    }
-
-    private fun details(app: RecentApp) {
-        startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${app.packageName}")))
-    }
-
-    @Composable
-    private fun Screen() {
-        MaterialTheme(colorScheme = lightColorScheme(
-            primary = Color.Black, onPrimary = Color.White, background = Color.White,
-            onBackground = Color.Black, surface = Color.White, onSurface = Color.Black,
-        )) {
-            Surface(Modifier.fillMaxSize()) {
-                Box(Modifier.safeDrawingPadding().fillMaxSize(), contentAlignment = Alignment.Center) {
-                    when {
-                        !allowed -> Column(
-                            Modifier.padding(28.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
-                            horizontalAlignment = Alignment.CenterHorizontally,
-                        ) {
-                            Text("To list recent apps, SubRead needs usage access.", textAlign = TextAlign.Center)
-                            Button(onClick = { startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)) }) {
-                                Text("Open the setting")
-                            }
-                        }
-                        apps.isEmpty() -> Text("Nothing recent.")
-                        else -> Cards()
-                    }
-                }
-            }
-        }
-    }
-
-    @OptIn(ExperimentalFoundationApi::class)
-    @Composable
-    private fun Cards() {
-        val state = rememberLazyListState()
-        val screen = LocalConfiguration.current.screenWidthDp.dp
-        val card = screen * 0.62f
-        LazyRow(
-            state = state,
-            // One card settles in the middle; its neighbours peek in at the sides.
-            flingBehavior = rememberSnapFlingBehavior(state),
-            contentPadding = PaddingValues(horizontal = (screen - card) / 2),
-            horizontalArrangement = Arrangement.spacedBy(18.dp),
-            verticalAlignment = Alignment.CenterVertically,
-            modifier = Modifier.fillMaxSize(),
-        ) {
-            items(apps, key = { it.packageName }) { app ->
-                val icon = remember(app.packageName) { app.icon.toBitmap(192, 192).asImageBitmap() }
-                Column(
-                    Modifier
-                        .size(width = card, height = card * 1.35f)
-                        .border(2.dp, Color.Black, RoundedCornerShape(18.dp))
-                        .combinedClickable(onClick = { open(app) }, onLongClick = { details(app) })
-                        .padding(18.dp),
-                    verticalArrangement = Arrangement.Center,
-                    horizontalAlignment = Alignment.CenterHorizontally,
-                ) {
-                    Image(icon, contentDescription = null, modifier = Modifier.size(96.dp))
-                    Text(app.label, fontWeight = FontWeight.Bold, maxLines = 2, textAlign = TextAlign.Center,
-                        overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 16.dp).fillMaxWidth())
-                    Text(ago(app.lastUsed), style = MaterialTheme.typography.bodySmall,
-                        modifier = Modifier.padding(top = 4.dp))
-                }
-            }
-        }
-    }
-
-    private fun ago(at: Long): String {
-        val minutes = (System.currentTimeMillis() - at) / 60_000
-        return when {
-            minutes < 1 -> "just now"
-            minutes < 60 -> "$minutes min ago"
-            minutes < 48 * 60 -> "${minutes / 60} h ago"
-            else -> "${minutes / (24 * 60)} days ago"
-        }
-    }
-}
-
-/** Quick-settings tile: the switcher from anywhere, one swipe and a tap. */
-class RecentsTile : TileService() {
-    override fun onClick() {
-        val intent = Intent(this, RecentsActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
-        if (android.os.Build.VERSION.SDK_INT >= 34) {
-            startActivityAndCollapse(android.app.PendingIntent.getActivity(
-                this, 0, intent, android.app.PendingIntent.FLAG_IMMUTABLE))
-        } else {
-            @Suppress("DEPRECATION", "StartActivityAndCollapseDeprecated")
-            startActivityAndCollapse(intent)
-        }
-    }
-}
diff --git a/app/src/main/kotlin/space/subread/app/whisper/Whisper.kt b/app/src/main/kotlin/space/subread/app/whisper/Whisper.kt
index 19ea2d1..4e628cd 100644
--- a/app/src/main/kotlin/space/subread/app/whisper/Whisper.kt
+++ b/app/src/main/kotlin/space/subread/app/whisper/Whisper.kt
@@ -35,7 +35,7 @@ class Whisper private constructor(private var ptr: Long) : AutoCloseable {
     fun transcribe(samples: FloatArray, language: String, offset: Double): List<TranscriptSegment> {
         when (val rc = WhisperLib.transcribe(ptr, samples, threads, language)) {
             0 -> {}
-            -1 -> throw TranscriptionCancelled()
+            1000 -> throw TranscriptionCancelled()
             else -> throw WhisperUnavailable("The speech model failed on this audio (code $rc).")
         }
         return (0 until WhisperLib.segmentCount(ptr)).mapNotNull { i ->
diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml
deleted file mode 100644
index c8b9f92..0000000
--- a/app/src/main/res/drawable/ic_notification.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
-    android:width="24dp" android:height="24dp"
-    android:viewportWidth="108" android:viewportHeight="108">
-    <path android:fillColor="#FFFFFF" android:pathData="M30,34 C38,30 46,30 53,34 L53,76 C46,72 38,72 30,76 Z M78,34 C70,30 62,30 55,34 L55,76 C62,72 70,72 78,76 Z" />
-</vector>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 9d1a3de..42628ad 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,5 +1,4 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
     <string name="app_name">SubRead</string>
-    <string name="recents">Recent apps</string>
 </resources>
diff --git a/tools/write_res.py b/tools/write_res.py
index 69a744a..ac8873c 100644
--- a/tools/write_res.py
+++ b/tools/write_res.py
@@ -20,7 +20,6 @@ w("xml/file_paths.xml", HEAD + """<paths>
 
 w("values/strings.xml", HEAD + """<resources>
     <string name="app_name">SubRead</string>
-    <string name="recents">Recent apps</string>
 </resources>
 """)
 
@@ -54,7 +53,6 @@ def vector(size: int, paths: list[tuple[str, str]]) -> str:
 
 
 w("drawable/ic_launcher_foreground.xml", vector(108, [("#000000", BOOK), ("#FFFFFF", WAVE)]))
-w("drawable/ic_notification.xml", vector(24, [("#FFFFFF", BOOK)]))
 
 for name in ("ic_launcher", "ic_launcher_round"):
     w(f"mipmap-anydpi-v26/{name}.xml", HEAD + """<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">