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