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 1b52b05fd8797783930b1e94d817dc7f63abdda8
truex <truex@equwal.com>
2026-09-21 04:44:40 -0700

Open Ink Recents where the system shows its recent apps

Android does not let an app take the place of the recent-apps screen of
the system. The key filter already gets the window-state events. When
the screen in front is the recents of the system (the task manager of
the Viwoods launcher, or the two stock Android ones), the service sends
Back and starts Ink Recents. A switch on Home and recents turns it off.
It is on when Ink Recents is installed.

The accessibility disclosure and the Play declaration name this use.

Version 0.0.15-alpha, version code 15.

 CHANGELOG.md                                       | 12 +++++++++++
 .../java/dev/equwal/assistkey/device/Device.kt     | 23 ++++++++++++++++++++
 .../equwal/assistkey/engine/KeyFilterService.kt    | 19 ++++++++++++++++
 .../dev/equwal/assistkey/store/SettingsFile.kt     |  2 +-
 .../equwal/assistkey/ui/AccessibilityDisclosure.kt |  4 +++-
 .../dev/equwal/assistkey/ui/HomeHubActivity.kt     | 10 +++++++++
 .../equwal/assistkey/device/SystemRecentsTest.kt   | 25 ++++++++++++++++++++++
 gradle.properties                                  |  4 ++--
 play/DECLARATIONS.md                               |  4 +++-
 9 files changed, 98 insertions(+), 5 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2108129..3870891 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,17 @@
 # Changelog
 
+## 0.0.15-alpha - 2026-09-21
+
+Version code 15.
+
+- **The Recents button opens Ink Recents.** Android does not let an app take
+  the place of the recent-apps screen of the system. Rebind now sees that
+  screen open (the task manager of the Viwoods launcher, or the stock Android
+  one), closes it, and opens Ink Recents. It works for the Recents button of
+  the bar, for the swipe, and for the Recents action. Home and recents has the
+  switch "Recents button opens Ink Recents". It is on when Ink Recents is
+  installed. Tests: `SystemRecentsTest`.
+
 ## 0.0.14-alpha - 2026-09-21
 
 Version code 14.
diff --git a/app/src/main/java/dev/equwal/assistkey/device/Device.kt b/app/src/main/java/dev/equwal/assistkey/device/Device.kt
index 561c2f8..2e59b07 100644
--- a/app/src/main/java/dev/equwal/assistkey/device/Device.kt
+++ b/app/src/main/java/dev/equwal/assistkey/device/Device.kt
@@ -62,6 +62,29 @@ object Device {
         c.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(K_AI_RETURNS, on).apply()
     }
 
+    private const val K_INK_RECENTS = "ink_recents_for_system"
+
+    /**
+     * True for the recent-apps screen of the system: the task manager of the
+     * Viwoods launcher, and the two screens that stock Android uses. Android
+     * does not let an app take the place of that screen, so the key filter
+     * watches for it. See KeyFilterService.
+     */
+    fun isSystemRecents(pkg: String?, cls: String?): Boolean = when {
+        pkg == null || cls == null -> false
+        pkg == "com.viwoods.launcher" -> cls.endsWith(".task_manager.TaskManagerActivity")
+        else -> cls == "com.android.quickstep.RecentsActivity" ||
+            cls == "com.android.systemui.recents.RecentsActivity"
+    }
+
+    /** The user wants Ink Recents where the system shows its own recent apps. On until switched off. */
+    fun inkRecentsForSystem(c: Context): Boolean =
+        c.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(K_INK_RECENTS, true)
+
+    fun setInkRecentsForSystem(c: Context, on: Boolean) {
+        c.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(K_INK_RECENTS, on).apply()
+    }
+
     /** True for the Viwoods AI assistant and for the Viwoods crop and screenshot-edit screens. */
     fun isAiScreen(pkg: String?, cls: String?): Boolean =
         pkg == "com.viwoods.viwoodsai" ||
diff --git a/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt b/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt
index 03b28ed..740bbf9 100644
--- a/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt
+++ b/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt
@@ -259,6 +259,8 @@ class KeyFilterService : AccessibilityService(), GestureEngine.Host {
         val pkg = event.packageName?.toString() ?: return
         val cls = event.className?.toString()
         when {
+            // Before the line that skips the system UI: stock Android has its recents there.
+            dev.equwal.assistkey.device.Device.isSystemRecents(pkg, cls) -> swapInInkRecents()
             pkg == packageName || pkg == "android" || pkg == "com.android.systemui" -> Unit
             dev.equwal.assistkey.device.Device.isAiScreen(pkg, cls) -> inAiScreen = true
             // Dialogs and keyboards report window changes too; only activities count.
@@ -269,6 +271,23 @@ class KeyFilterService : AccessibilityService(), GestureEngine.Host {
         }
     }
 
+    /**
+     * The Recents button of the bar, the swipe, and the Recents action all open
+     * the recent-apps screen of the system. Where the user has Ink Recents, this
+     * closes that screen and opens Ink Recents. Android has no other way to put
+     * an app in that place.
+     */
+    private fun swapInInkRecents() {
+        if (!License.active(this) || !dev.equwal.assistkey.device.Device.inkRecentsForSystem(this)) return
+        val open = android.content.Intent(dev.equwal.assistkey.home.RecentsActivity.ACTION_OPEN)
+            .setPackage(dev.equwal.assistkey.home.RecentsActivity.INK_RECENTS)
+            .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
+        if (open.resolveActivity(packageManager) == null) return
+        // Back first, so that the screen of the system is not left under Ink Recents.
+        performGlobalAction(GLOBAL_ACTION_BACK)
+        handler.postDelayed({ runCatching { startActivity(open) } }, 120L)
+    }
+
     private fun isActivity(pkg: String, cls: String): Boolean = runCatching {
         packageManager.getActivityInfo(android.content.ComponentName(pkg, cls), 0); true
     }.getOrDefault(false)
diff --git a/app/src/main/java/dev/equwal/assistkey/store/SettingsFile.kt b/app/src/main/java/dev/equwal/assistkey/store/SettingsFile.kt
index 8e297a4..69b88d4 100644
--- a/app/src/main/java/dev/equwal/assistkey/store/SettingsFile.kt
+++ b/app/src/main/java/dev/equwal/assistkey/store/SettingsFile.kt
@@ -30,7 +30,7 @@ object SettingsFile {
         "assistkey_channels" to null,
         "assistkey_nav" to setOf("buttons", "gestures", "keys"),
         "assistkey_voice" to setOf("engine", "language"),
-        "assistkey_device" to setOf("ai_key_returns"),
+        "assistkey_device" to setOf("ai_key_returns", "ink_recents_for_system"),
         "assistkey_power" to setOf("wanted")
     )
 
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/AccessibilityDisclosure.kt b/app/src/main/java/dev/equwal/assistkey/ui/AccessibilityDisclosure.kt
index 290c63d..a1ab0e8 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/AccessibilityDisclosure.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/AccessibilityDisclosure.kt
@@ -25,7 +25,9 @@ object AccessibilityDisclosure {
             "- puts dictated words in the focused text field, never a password " +
             "field;\n" +
             "- shows a small on-screen button only when you bind one;\n" +
-            "- remembers the front app's name, in memory only.\n\n" +
+            "- remembers the front app's name, in memory only;\n" +
+            "- sees the recent apps of the system open, to open Ink Recents in " +
+            "their place, if you have it.\n\n" +
             "Nothing is collected, stored or sent. The app has no internet permission."
 
     fun show(a: Activity, onAgree: () -> Unit, onDecline: () -> Unit = {}) {
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/HomeHubActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/HomeHubActivity.kt
index 07c8a74..a210649 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/HomeHubActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/HomeHubActivity.kt
@@ -3,6 +3,9 @@ package dev.equwal.assistkey.ui
 import android.app.Activity
 import android.content.Intent
 import dev.equwal.assistkey.bundle.Bundled
+import dev.equwal.assistkey.ui.Ui.check
+import dev.equwal.assistkey.home.RecentsActivity
+import dev.equwal.assistkey.device.Device
 import dev.equwal.assistkey.ui.Ui.row
 
 /** The home screen and the recent-apps screen. Both are apps of their own, which this screen offers. */
@@ -16,6 +19,13 @@ class HomeHubActivity : Activity() {
     private fun build() {
         val col = Ui.page(this, "Home and recents")
 
+        if (runCatching { packageManager.getPackageInfo(RecentsActivity.INK_RECENTS, 0) }.isSuccess) {
+            col.check(
+                "Recents button opens Ink Recents",
+                "In place of the recent apps of the system",
+                Device.inkRecentsForSystem(this)
+            ) { Device.setInkRecentsForSystem(this, it) }
+        }
         BundledRows.add(this, col, Bundled.HOME)
     }
 }
diff --git a/app/src/test/java/dev/equwal/assistkey/device/SystemRecentsTest.kt b/app/src/test/java/dev/equwal/assistkey/device/SystemRecentsTest.kt
new file mode 100644
index 0000000..150acd4
--- /dev/null
+++ b/app/src/test/java/dev/equwal/assistkey/device/SystemRecentsTest.kt
@@ -0,0 +1,25 @@
+package dev.equwal.assistkey.device
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class SystemRecentsTest {
+
+    /** Seen on the Viwoods reader when the Recents button was pressed. */
+    @Test fun `the task manager of the Viwoods launcher is the recents of the system`() {
+        assertTrue(Device.isSystemRecents("com.viwoods.launcher", "com.viwoods.launcher.task_manager.TaskManagerActivity"))
+    }
+
+    @Test fun `the home screen of the same launcher is not`() {
+        assertFalse(Device.isSystemRecents("com.viwoods.launcher", "com.viwoods.launcher.main.LauncherMainActivity"))
+    }
+
+    @Test fun `stock Android recents count, other screens and Ink Recents do not`() {
+        assertTrue(Device.isSystemRecents("com.android.launcher3", "com.android.quickstep.RecentsActivity"))
+        assertTrue(Device.isSystemRecents("com.android.systemui", "com.android.systemui.recents.RecentsActivity"))
+        assertFalse(Device.isSystemRecents("dev.equwal.inkrecents", "dev.equwal.inkrecents.RecentsActivity"))
+        assertFalse(Device.isSystemRecents(null, null))
+        assertFalse(Device.isSystemRecents("com.viwoods.launcher", null))
+    }
+}
diff --git a/gradle.properties b/gradle.properties
index beb32c4..ecedcea 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.14-alpha
-assistkey.versionCode=14
+assistkey.versionName=0.0.15-alpha
+assistkey.versionCode=15
 
 # 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/DECLARATIONS.md b/play/DECLARATIONS.md
index 0eaaf93..06014c7 100644
--- a/play/DECLARATIONS.md
+++ b/play/DECLARATIONS.md
@@ -64,7 +64,7 @@ disabilities)?** No. The manifest does not set `isAccessibilityTool`.
 ```
 AssistKey is a hardware key remapper for the Viwoods AiPaper Reader, an e-ink reading device. Remapping hardware keys is the app's only function, and the AccessibilityService API is the only public Android API that can do it.
 
-The service is used for five things:
+The service is used for six things:
 
 1. Key event filtering (flagRequestFilterKeyEvents / onKeyEvent). The service receives presses of the device's AI key and volume keys, recognises the gesture the user configured - single or multiple taps, press-and-hold, or a combination of keys - and consumes the press so that the user's chosen action runs instead of the default one.
 
@@ -76,6 +76,8 @@ The service is used for five things:
 
 5. An on-screen button, if the user binds one: the service draws a small round button in an accessibility overlay window (TYPE_ACCESSIBILITY_OVERLAY). A tap on it runs the action the user chose. The button exists only while it has an action, and it reads nothing from the screen.
 
+6. Ink Recents in place of the recent apps of the system, if the user has installed Ink Recents and left the switch on: from the same window-state events, the service recognises the recent-apps screen of the system by its package and class name, sends Back, and starts Ink Recents. It reads nothing from that screen.
+
 Window content is retrieved only in cases 2 and 3: to locate a scrollable node at the moment the user presses a key bound to the Scroll action, and to read the one focused text field at the moment recognised words are inserted into it. No window content, text, or key event is stored, logged or transmitted. The app does not request the INTERNET permission and cannot transmit anything.
 
 Before the user is sent to the accessibility settings, the app shows a prominent in-app disclosure describing exactly this use, and proceeds only if the user taps Agree.