commit 8044cb2174364c81baf3971d8f438dfcfe5bcf1d equwal <truex@equwal.com> 2026-09-20 21:05:04 -0700 Optional recent-apps switcher Off by default; a switch in the app enables a "Recent apps" launcher icon and a quick-settings tile (both disabled manifest components until then). Cards slide sideways and snap, stock-Android style; tap to switch, long-press for the app's settings page. An ordinary app cannot be the system recents screen: live thumbnails and swipe-to-close belong to the launcher. What it can have, once the user grants usage access, is the order apps were last in the foreground - so this shows icon and name, most recent first. Also exported as the action space.subread.app.RECENTS and as an ASSIST target, so a hardware key or gesture mapper can open it.
.github/workflows/ping.yml | 10 -- app/src/main/AndroidManifest.xml | 61 +++++++- .../main/kotlin/space/subread/app/MainActivity.kt | 35 +++++ .../kotlin/space/subread/app/recents/RecentApps.kt | 100 +++++++++++++ .../space/subread/app/recents/RecentsActivity.kt | 164 +++++++++++++++++++++ app/src/main/res/values/strings.xml | 1 + tools/write_res.py | 1 + 7 files changed, 361 insertions(+), 11 deletions(-)
diff --git a/.github/workflows/ping.yml b/.github/workflows/ping.yml deleted file mode 100644 index 20064ec..0000000 --- a/.github/workflows/ping.yml +++ /dev/null @@ -1,10 +0,0 @@ -name: Ping -"on": - push: - branches: [main] - workflow_dispatch: -jobs: - ping: - runs-on: ubuntu-latest - steps: - - run: echo ok diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7dd66a8..1bce150 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,10 +1,23 @@ <?xml version="1.0" encoding="utf-8"?> -<manifest xmlns:android="http://schemas.android.com/apk/res/android"> +<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" + 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> <application android:allowBackup="false" @@ -24,6 +37,52 @@ </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> + <!-- Special use, not dataSync or mediaProcessing: those are stopped by the system after six hours, and a long book takes longer. --> <service diff --git a/app/src/main/kotlin/space/subread/app/MainActivity.kt b/app/src/main/kotlin/space/subread/app/MainActivity.kt index d2bfbb3..8dbc8ab 100644 --- a/app/src/main/kotlin/space/subread/app/MainActivity.kt +++ b/app/src/main/kotlin/space/subread/app/MainActivity.kt @@ -28,6 +28,7 @@ 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,6 +48,8 @@ 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 class MainActivity : ComponentActivity() { @@ -171,6 +174,38 @@ private fun App() { onSave = { status.srt?.let { saveSrt.launch(it.name) } }, 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/recents/RecentApps.kt b/app/src/main/kotlin/space/subread/app/recents/RecentApps.kt new file mode 100644 index 0000000..8675eaf --- /dev/null +++ b/app/src/main/kotlin/space/subread/app/recents/RecentApps.kt @@ -0,0 +1,100 @@ +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 new file mode 100644 index 0000000..ddc0796 --- /dev/null +++ b/app/src/main/kotlin/space/subread/app/recents/RecentsActivity.kt @@ -0,0 +1,164 @@ +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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5b7b417..c33ed8e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,4 +3,5 @@ <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/tools/write_res.py b/tools/write_res.py index a60b764..d89e0d3 100644 --- a/tools/write_res.py +++ b/tools/write_res.py @@ -22,6 +22,7 @@ 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> """)