Recently Written · git

assistkey

Hardware key remapper for the Viwoods AiPaper Reader: AI key, volume keys and power, with multi-tap, hold and chords

git clone https://github.com/equwal/assistkey

Log | Files | Refs


commit 4de4826a7e15c5f7fb14cd763c7a2aa951596e3e
truex <truex@equwal.com>
2026-09-20 23:59:54 -0700

Recent apps: a row of swipe cards in place of the list (0.0.5-alpha)

RecentsActivity now shows the recent apps as cards, like the Android switcher.
Swipe sideways through the cards, tap a card to open the app, swipe a card up
to close it. The cards are drawn for e-ink: black outlines on white, no fling,
and the row moves one card for each swipe with no animation.

With shell access the cards are the real tasks of the system. A swipe up runs
`am stack remove <task id>`, which the shell user may do. Where the shell is
root, each card shows the picture the system keeps of the task, read from
/data/system_ce/0/snapshots. Those files belong to `system`, so a plain shell
cannot read them, and the card then shows the app icon.

The task list parser now accepts both forms of a task line: `A=<uid>:<package>`
and `I=<package>/<class>`.

The list view is deleted; the cards replace it.

 CHANGELOG.md                                       |  11 +
 README.md                                          |   5 +-
 .../main/java/dev/equwal/assistkey/home/Apps.kt    |  36 +++-
 .../dev/equwal/assistkey/home/RecentsActivity.kt   | 230 +++++++++++++++++----
 .../equwal/assistkey/ui/ActionPickerActivity.kt    |   2 +-
 .../java/dev/equwal/assistkey/ui/MainActivity.kt   |   2 +-
 gradle.properties                                  |   4 +-
 play/LISTING.md                                    |   2 +-
 8 files changed, 233 insertions(+), 59 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c7b6c3b..bee96fb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,16 @@
 # Changelog
 
+## 0.0.5-alpha - 2026-09-21
+
+Version code 5.
+
+- **Recent apps as cards.** The list is now a row of cards, like the Android
+  switcher: swipe sideways, tap a card to open it, swipe a card up to close it.
+  It is drawn for e-ink: outlines only, and the row jumps from card to card with
+  no animation. With shell access a card is a real task and can be closed
+  (`am stack remove`). Where the shell is root, a card shows the picture the
+  system keeps of the task. Otherwise a card shows the app icon.
+
 ## 0.0.4-alpha - 2026-09-20
 
 Version code 4.
diff --git a/README.md b/README.md
index f8a28c0..a629b54 100644
--- a/README.md
+++ b/README.md
@@ -117,7 +117,10 @@ the whole chain can be tested on a bench with no voice.
 app list is hidden until you type, and a single match opens itself. It is
 original code: the idea is shared with CLauncher/Olauncher, which are GPL-3.0
 and therefore cannot be copied into this app. Disabled in the manifest until
-switched on. Recent apps is a text list in place of the screenshot carousel.
+switched on. Recent apps is a row of cards like the Android switcher, drawn for
+e-ink: outlines, no animation, one jump per card. A card closes with a swipe up
+when there is shell access, and shows the task picture the system keeps when
+the shell is root (`/data/system_ce/0/snapshots`, owned by `system`).
 
 ## Why it is shaped like this
 
diff --git a/app/src/main/java/dev/equwal/assistkey/home/Apps.kt b/app/src/main/java/dev/equwal/assistkey/home/Apps.kt
index f58f534..20e7420 100644
--- a/app/src/main/java/dev/equwal/assistkey/home/Apps.kt
+++ b/app/src/main/java/dev/equwal/assistkey/home/Apps.kt
@@ -96,17 +96,37 @@ object Apps {
         return rank(c, last.entries.sortedByDescending { it.value }.map { it.key }, limit)
     }
 
-    /** Most recent first, from the system's real task list. Needs shell access. */
-    fun recentFromShell(c: Context, limit: Int = 12, done: (List<App>?) -> Unit) {
+    /** One entry of the recent-apps list. [taskId] is known only with shell access. */
+    data class Recent(val app: App, val taskId: Int?)
+
+    /**
+     * Most recent first, from the real task list of the system. Needs shell
+     * access. A task line names its app as `A=<uid>:<package>` or, for some
+     * tasks, as `I=<package>/<class>`.
+     */
+    fun recentFromShell(c: Context, limit: Int = 12, done: (List<Recent>?) -> Unit) {
         if (!Shell.ready) return done(null)
         Shell.run("dumpsys activity recents") { r ->
             if (!r.ok) return@run done(null)
-            val task = Regex("""Recent #\d+: Task\{\S+ #\d+ type=(\w+) A=\d+:(\S+)""")
-            val pkgs = task.findAll(r.output)
-                .filter { it.groupValues[1] == "standard" }
-                .map { it.groupValues[2].trimEnd('}') }
-                .toList()
-            done(rank(c, pkgs, limit))
+            val task = Regex("""Recent #\d+: Task\{\S+ #(\d+) type=(\w+) (?:A=\d+:|I=)([^\s/}]+)""")
+            val ids = LinkedHashMap<String, Int>()
+            task.findAll(r.output)
+                .filter { it.groupValues[2] == "standard" }
+                .forEach { ids.putIfAbsent(it.groupValues[3], it.groupValues[1].toInt()) }
+            done(rank(c, ids.keys.toList(), limit).map { Recent(it, ids[it.pkg]) })
+        }
+    }
+
+    /**
+     * The picture the system keeps of a task. The files belong to `system`, so
+     * this works only where the shell is root, or may use `su`. Null otherwise.
+     */
+    fun snapshot(taskId: Int, done: (android.graphics.Bitmap?) -> Unit) {
+        val dir = "/data/system_ce/0/snapshots/"
+        val pick = "f=$dir${taskId}_reduced.jpg; [ -e \$f ] || f=$dir$taskId.jpg; "
+        Shell.run(pick + "base64 -w 0 \$f 2>/dev/null || su 0 base64 -w 0 \$f 2>/dev/null") { r ->
+            val bytes = runCatching { android.util.Base64.decode(r.output.trim(), android.util.Base64.DEFAULT) }.getOrNull()
+            done(bytes?.takeIf { it.size > 100 }?.let { android.graphics.BitmapFactory.decodeByteArray(it, 0, it.size) })
         }
     }
 
diff --git a/app/src/main/java/dev/equwal/assistkey/home/RecentsActivity.kt b/app/src/main/java/dev/equwal/assistkey/home/RecentsActivity.kt
index 422afc5..93a35e5 100644
--- a/app/src/main/java/dev/equwal/assistkey/home/RecentsActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/home/RecentsActivity.kt
@@ -1,35 +1,53 @@
 package dev.equwal.assistkey.home
 
 import android.app.Activity
-import android.app.AlertDialog
 import android.content.Intent
-import android.net.Uri
+import android.graphics.Color
+import android.graphics.Typeface
+import android.graphics.drawable.GradientDrawable
 import android.provider.Settings
+import android.util.TypedValue
+import android.view.GestureDetector
+import android.view.Gravity
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
+import android.view.WindowInsets
+import android.widget.HorizontalScrollView
+import android.widget.ImageView
 import android.widget.LinearLayout
+import android.widget.TextView
 import dev.equwal.assistkey.shell.Shell
 import dev.equwal.assistkey.ui.ShellActivity
 import dev.equwal.assistkey.ui.Ui
 import dev.equwal.assistkey.ui.Ui.button
-import dev.equwal.assistkey.ui.Ui.header
+import dev.equwal.assistkey.ui.Ui.dp
 import dev.equwal.assistkey.ui.Ui.note
-import dev.equwal.assistkey.ui.Ui.row
 import dev.equwal.assistkey.ui.Ui.title
+import kotlin.math.abs
 
 /**
- * Recent apps, as a list.
+ * Recent apps, as a row of cards.
  *
- * The stock switcher is a carousel of screenshots - the worst thing an e-ink
- * panel can be asked to draw. This is the same information as text: most recent
- * first, one tap to go back to it.
+ * It works like the Android switcher: swipe sideways through the cards, tap a
+ * card to go to that app, swipe a card up to close it. It is drawn for e-ink:
+ * black outlines on white, and the row jumps from card to card with no
+ * animation.
  *
- * Android does not tell apps what is running. With shell access the list is the
- * real task list of the system, and an app can be closed from it. Without, it
- * is rebuilt from the usage log, which needs the usage-access grant.
+ * Android does not tell apps what is running. With shell access the cards are
+ * the real tasks of the system, a card can be closed, and where the shell is
+ * root each card shows the picture the system keeps of the task. Without shell
+ * access the order comes from the usage log and a card shows the app icon.
  *
- * Bind it to a key from any action list: Navigation > Recent apps (list).
+ * Bind it to a key from any action list: AssistKey > Recent apps.
  */
 class RecentsActivity : Activity() {
 
+    private var row: LinearLayout? = null
+    private var scroller: HorizontalScrollView? = null
+    private var cardStep = 0
+
     override fun onResume() {
         super.onResume()
         load()
@@ -40,58 +58,180 @@ class RecentsActivity : Activity() {
             if (isFinishing) return@recentFromShell
             when {
                 fromShell != null -> show(fromShell, closable = true)
-                Apps.hasUsageAccess(this) -> show(Apps.recentFromUsage(this), closable = false)
+                Apps.hasUsageAccess(this) ->
+                    show(Apps.recentFromUsage(this).map { Apps.Recent(it, null) }, closable = false)
                 else -> askForAccess()
             }
         }
     }
 
-    private fun show(apps: List<Apps.App>, closable: Boolean) {
-        val col = Ui.page(this)
-        col.title("Recent apps")
-        if (apps.isEmpty()) col.note("Nothing recent.")
-        apps.forEach { app -> entry(col, app, closable) }
-        if (closable && apps.isNotEmpty()) {
-            col.header("All of them")
-            col.button("Close all") {
-                Shell.runAll(apps.map { "am force-stop " + it.pkg }) { load() }
+    // ---- the cards -------------------------------------------------------------------------
+
+    private fun show(recents: List<Apps.Recent>, closable: Boolean) {
+        val screenW = resources.displayMetrics.widthPixels
+        val screenH = resources.displayMetrics.heightPixels
+        val cardW = (screenW * 0.72f).toInt()
+        val cardH = (screenH * 0.62f).toInt()
+        val gap = dp(16)
+        cardStep = cardW + gap
+
+        val root = LinearLayout(this).apply {
+            orientation = LinearLayout.VERTICAL
+            setBackgroundColor(Color.WHITE)
+            setOnApplyWindowInsetsListener { v, insets ->
+                val bars = insets.getInsets(WindowInsets.Type.systemBars())
+                v.setPadding(bars.left, bars.top, bars.right, bars.bottom)
+                insets
             }
-            col.note("Press and hold an app to close just that one.")
         }
-        if (!closable && Shell.SUPPORTED) {
-            col.note("With shell access this list is exact, and apps can be closed from it.")
+        root.addView(TextView(this).apply {
+            text = if (recents.isEmpty()) "Nothing recent" else "Recent apps"
+            setTextColor(Color.BLACK)
+            setTextSize(TypedValue.COMPLEX_UNIT_SP, 22f)
+            typeface = Typeface.DEFAULT_BOLD
+            setPadding(dp(20), dp(16), dp(20), dp(12))
+        })
+
+        val cards = LinearLayout(this).apply {
+            orientation = LinearLayout.HORIZONTAL
+            // Side padding puts the first and the last card in the middle too.
+            val side = (screenW - cardW) / 2
+            setPadding(side, 0, side, 0)
+        }
+        recents.forEach { r ->
+            cards.addView(
+                card(r, closable),
+                LinearLayout.LayoutParams(cardW, cardH).apply { marginEnd = gap }
+            )
         }
-    }
+        row = cards
 
-    private fun entry(col: LinearLayout, app: Apps.App, closable: Boolean) {
-        val row = col.row(app.label, null) {
-            Apps.launch(this, app)
-            finish()
+        val scroll = object : HorizontalScrollView(this) {
+            override fun onTouchEvent(ev: MotionEvent): Boolean {
+                val handled = super.onTouchEvent(ev)
+                if (ev.actionMasked == MotionEvent.ACTION_UP || ev.actionMasked == MotionEvent.ACTION_CANCEL) snap()
+                return handled
+            }
+
+            /** No fling and no glide: e-ink shows every frame of one as a smear. */
+            override fun fling(velocityX: Int) = Unit
+        }.apply {
+            isHorizontalScrollBarEnabled = false
+            overScrollMode = View.OVER_SCROLL_NEVER
+            addView(cards, LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT))
         }
-        if (!closable) return
-        row.setOnLongClickListener {
-            AlertDialog.Builder(this).setTitle(app.label)
-                .setItems(arrayOf("Close", "App info")) { _, which ->
-                    if (which == 0) {
-                        Shell.run("am force-stop " + app.pkg) { load() }
-                    } else {
-                        runCatching {
-                            startActivity(
-                                Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:" + app.pkg))
-                            )
-                        }
+        scroller = scroll
+        root.addView(scroll, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT))
+
+        root.addView(TextView(this).apply {
+            text = if (closable) "Tap a card to open it. Swipe a card up to close it."
+            else "Tap a card to open it."
+            setTextColor(Color.rgb(70, 70, 70))
+            setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
+            gravity = Gravity.CENTER_HORIZONTAL
+            setPadding(dp(20), dp(14), dp(20), dp(6))
+        })
+        if (closable && recents.isNotEmpty()) {
+            root.addView(
+                android.widget.Button(this).apply {
+                    text = "Close all"
+                    setOnClickListener {
+                        Shell.runAll(recents.mapNotNull { it.taskId }.map { "am stack remove $it" }) { load() }
                     }
-                }.show()
-            true
+                },
+                LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { gravity = Gravity.CENTER_HORIZONTAL }
+            )
         }
+        setContentView(root)
     }
 
+    /** Puts the nearest card in the middle, in one step. */
+    private fun snap() {
+        val s = scroller ?: return
+        if (cardStep <= 0) return
+        val index = ((s.scrollX + cardStep / 2) / cardStep).coerceIn(0, (row?.childCount ?: 1) - 1)
+        s.scrollTo(index * cardStep, 0)
+    }
+
+    private fun card(r: Apps.Recent, closable: Boolean): View {
+        val box = LinearLayout(this).apply {
+            orientation = LinearLayout.VERTICAL
+            background = GradientDrawable().apply {
+                setColor(Color.WHITE)
+                setStroke(dp(2), Color.BLACK)
+                cornerRadius = dp(14).toFloat()
+            }
+            val p = dp(3)
+            setPadding(p, p, p, p)
+            clipToOutline = true
+        }
+
+        val icon = runCatching { packageManager.getApplicationIcon(r.app.pkg) }.getOrNull()
+        box.addView(LinearLayout(this).apply {
+            orientation = LinearLayout.HORIZONTAL
+            gravity = Gravity.CENTER_VERTICAL
+            setPadding(dp(12), dp(10), dp(12), dp(10))
+            addView(ImageView(context).apply { setImageDrawable(icon) }, LinearLayout.LayoutParams(dp(32), dp(32)))
+            addView(TextView(context).apply {
+                text = r.app.label
+                setTextColor(Color.BLACK)
+                setTextSize(TypedValue.COMPLEX_UNIT_SP, 17f)
+                setSingleLine()
+                setPadding(dp(10), 0, 0, 0)
+            })
+        })
+        box.addView(View(this).apply { setBackgroundColor(Color.BLACK) }, LinearLayout.LayoutParams(MATCH_PARENT, dp(1)))
+
+        // The body: the app icon now, the task picture when and if it arrives.
+        val body = ImageView(this).apply {
+            setImageDrawable(icon)
+            scaleType = ImageView.ScaleType.CENTER_INSIDE
+            val p = dp(64)
+            setPadding(p, p, p, p)
+        }
+        box.addView(body, LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f))
+        r.taskId?.let { id ->
+            Apps.snapshot(id) { picture ->
+                if (picture != null && !isFinishing) {
+                    body.setPadding(0, 0, 0, 0)
+                    body.scaleType = ImageView.ScaleType.FIT_START
+                    body.setImageBitmap(picture)
+                }
+            }
+        }
+
+        val gestures = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() {
+            override fun onDown(e: MotionEvent): Boolean = true
+
+            override fun onSingleTapUp(e: MotionEvent): Boolean {
+                Apps.launch(this@RecentsActivity, r.app)
+                finish()
+                return true
+            }
+
+            override fun onFling(e1: MotionEvent?, e2: MotionEvent, vx: Float, vy: Float): Boolean {
+                val up = e1 != null && e1.y - e2.y > box.height / 5 && abs(vy) > abs(vx)
+                if (!up || !closable || r.taskId == null) return false
+                Shell.run("am stack remove " + r.taskId) { load() }
+                return true
+            }
+        })
+        box.setOnTouchListener { v, e ->
+            if (e.actionMasked == MotionEvent.ACTION_UP) v.performClick()
+            gestures.onTouchEvent(e)
+        }
+        return box
+    }
+
+    // ---- no access yet ---------------------------------------------------------------------
+
     private fun askForAccess() {
         val col = Ui.page(this)
         col.title("Recent apps")
         col.note(
             "Android does not tell apps what has been used recently. Give AssistKey " +
-                "usage access and it can put your apps in order of last use." +
+                "usage access and it can put your apps in order of last use. On some " +
+                "devices the setting is named App usage data." +
                 if (Shell.SUPPORTED) " Shell access gives the exact list the system keeps." else ""
         )
         col.button("Give usage access") {
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/ActionPickerActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/ActionPickerActivity.kt
index e3c200f..8af03ea 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/ActionPickerActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/ActionPickerActivity.kt
@@ -79,7 +79,7 @@ class ActionPickerActivity : Activity() {
     private fun own(col: LinearLayout) {
         col.header("AssistKey")
         listOf(
-            "Recent apps (list)" to "dev.equwal.assistkey.home.RecentsActivity",
+            "Recent apps (cards)" to "dev.equwal.assistkey.home.RecentsActivity",
             "App search" to "dev.equwal.assistkey.home.HomeActivity"
         ).filter { (_, cls) ->
             // The home screen is switched off until asked for; a disabled
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt
index bf389c6..db55f8a 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt
@@ -241,7 +241,7 @@ class MainActivity : Activity() {
         col.row("Home screen", "A plain, fast launcher made for e-ink") {
             startActivity(Intent(this, dev.equwal.assistkey.home.HomeSettingsActivity::class.java))
         }
-        col.row("Recent apps", "A plain app switcher, bindable to any key") {
+        col.row("Recent apps", "Card switcher made for e-ink, bindable to any key") {
             startActivity(Intent(this, dev.equwal.assistkey.home.RecentsActivity::class.java))
         }
 
diff --git a/gradle.properties b/gradle.properties
index 985c935..2aaf277 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -6,8 +6,8 @@ android.nonTransitiveRClass=true
 kotlin.code.style=official
 
 # Release identity. versionCode must rise with every upload to Play.
-assistkey.versionName=0.0.4-alpha
-assistkey.versionCode=4
+assistkey.versionName=0.0.5-alpha
+assistkey.versionCode=5
 
 # The free beta ends on this date (UTC) at the latest. It is the backstop for
 # installs that cannot reach Google Play; the real switch is the
diff --git a/play/LISTING.md b/play/LISTING.md
index a66c0f5..50a404d 100644
--- a/play/LISTING.md
+++ b/play/LISTING.md
@@ -52,7 +52,7 @@ Bind a key to Voice typing: press, speak, and the words appear where the cursor
 
 MORE FOR E-INK
 • A plain text home screen: a clock, a few apps, type to find the rest
-• Recent apps as a list instead of a slow screenshot carousel
+• Recent apps as swipe cards, drawn for e-ink with no animation
 
 BUILT FOR E-INK