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