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