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 7becf58ad8536b26a8cd4ee8e2f3abe4d9cd5afb
truex <truex@equwal.com>
2026-09-20 15:38:32 -0700

Add a key tester, and call out the restricted-settings block

Two things only a real device would have shown.

Android silently reverts accessibility access for apps installed outside
an app store - the switch appears to turn on and then quietly goes back
off, with no message anywhere. There is no API to detect it, so the
accessibility channel now says so in place and links straight to App
info. Over adb the equivalent is appops set ACCESS_RESTRICTED_SETTINGS
allow.

Whether a key reaches an input filter at all is a per-firmware question
with no way to look it up, and events injected with adb shell input
keyevent bypass accessibility input filters entirely, so remote testing
proves nothing either way. The key tester records every event the filter
sees; a key that never appears is a key the window manager consumed
upstream, which is what Power looks like everywhere.

isAccessibilityOn now trusts the running service over the settings
string, since the service lives in this process and the string is itself
subject to read restrictions.

 README.md                                          | 35 +++++++-
 app/src/main/AndroidManifest.xml                   |  1 +
 .../java/dev/equwal/assistkey/channel/Channels.kt  | 26 +++++-
 .../equwal/assistkey/engine/KeyFilterService.kt    | 18 +++--
 .../java/dev/equwal/assistkey/engine/KeyLog.kt     | 65 +++++++++++++++
 .../dev/equwal/assistkey/ui/KeyTesterActivity.kt   | 92 ++++++++++++++++++++++
 .../java/dev/equwal/assistkey/ui/MainActivity.kt   | 28 ++++++-
 7 files changed, 253 insertions(+), 12 deletions(-)
diff --git a/README.md b/README.md
index 77f02f0..69caff5 100644
--- a/README.md
+++ b/README.md
@@ -69,7 +69,27 @@ Parity with the stock Viwoods key screen, plus everything it does not offer:
 
 ## Installing
 
-Grab `app-release.apk` from the release build and sideload it. Then, optionally:
+Grab `app-release.apk` from the release build and sideload it.
+
+### The accessibility switch will not stay on until you do this
+
+Android blocks **restricted settings** for anything installed outside an app
+store, and accessibility is one of them. The symptom is silent and confusing:
+the switch appears to turn on, then reverts a moment later with no message.
+
+*App info → three-dot menu → Allow restricted settings*, then enable the
+service. The app links straight to App info from the accessibility channel.
+
+Over adb the equivalent is:
+
+```bash
+adb shell appops set dev.equwal.assistkey ACCESS_RESTRICTED_SETTINGS allow
+```
+
+Note that `adb shell am force-stop` on this app disables its accessibility
+service again, so re-enable it after any force-stop or reinstall.
+
+### Optional: the firmware power switches
 
 ```bash
 adb shell pm grant dev.equwal.assistkey android.permission.WRITE_SECURE_SETTINGS
@@ -80,6 +100,19 @@ switches (short press, hold duration, the double-press gesture toggles). The app
 detects the refusal and prints the exact `adb` command instead of failing
 silently.
 
+Note that this permission only unlocks *writes*. Since Android 12 those keys
+cannot be **read** by a non-system app at all — `Settings.Global.getInt` throws
+`SecurityException` — so the app shows their current values as "unknown". That
+is a platform restriction, not a bug.
+
+### Key tester
+
+*Advanced → Key tester* lists every key event that reaches the filter. It is the
+only reliable way to find out what a given device allows, because a key consumed
+upstream by the window manager never reaches any app and simply never appears.
+Note that events injected with `adb shell input keyevent` **bypass**
+accessibility input filters entirely, so only real presses tell you anything.
+
 ## Building
 
 ```bash
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ac1de4d..dd1cb4f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -59,6 +59,7 @@
         <activity android:name=".ui.ChordActivity" android:exported="false" />
         <activity android:name=".ui.ViwoodsActivity" android:exported="false" />
         <activity android:name=".ui.TimingActivity" android:exported="false" />
+        <activity android:name=".ui.KeyTesterActivity" android:exported="false" />
 
         <!--
             The three impersonation entry points. Each starts disabled: the app
diff --git a/app/src/main/java/dev/equwal/assistkey/channel/Channels.kt b/app/src/main/java/dev/equwal/assistkey/channel/Channels.kt
index c719ebe..1ca57e2 100644
--- a/app/src/main/java/dev/equwal/assistkey/channel/Channels.kt
+++ b/app/src/main/java/dev/equwal/assistkey/channel/Channels.kt
@@ -8,6 +8,7 @@ import android.net.Uri
 import android.os.Build
 import android.provider.Settings
 import dev.equwal.assistkey.engine.KeyFilterService
+import dev.equwal.assistkey.route.ServiceHolder
 
 /**
  * Turning channels on and off, and telling the truth about whether the system
@@ -92,16 +93,35 @@ object Channels {
         }
     }
 
+    /**
+     * The running service is the authority when it is there - it lives in this
+     * process and sets the holder itself. The settings string is only a
+     * fallback for the window between "enabled" and "bound", and is read
+     * defensively because restricted settings can make it unreadable.
+     */
     fun isAccessibilityOn(c: Context): Boolean {
+        if (ServiceHolder.isRunning) return true
         val want = ComponentName(c.packageName, KeyFilterService::class.java.name)
-        val list = Settings.Secure.getString(
-            c.contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES
-        ) ?: return false
+        val list = runCatching {
+            Settings.Secure.getString(
+                c.contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES
+            )
+        }.getOrNull() ?: return false
         return list.split(":").any {
             ComponentName.unflattenFromString(it.trim()) == want
         }
     }
 
+    /**
+     * Android silently reverts accessibility for apps installed outside an app
+     * store until the user clears the restriction in App info. There is no API
+     * to read that state, so the app cannot detect it - it can only say so.
+     */
+    fun appInfoIntent(c: Context): Intent = Intent(
+        Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
+        Uri.parse("package:" + c.packageName)
+    ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+
     private fun roleManager(c: Context): RoleManager? =
         c.getSystemService(RoleManager::class.java)
 
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 277b123..b305ed6 100644
--- a/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt
+++ b/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt
@@ -49,15 +49,19 @@ class KeyFilterService : AccessibilityService(), GestureEngine.Host {
     }
 
     override fun onKeyEvent(event: KeyEvent): Boolean {
-        if (!Channels.isEnabled(this, Channel.ACCESSIBILITY)) return false
-        val key = HwKey.fromCode(event.keyCode) ?: return false
-        if (!key.interceptable) return false
-
-        return when (event.action) {
-            KeyEvent.ACTION_DOWN -> engine.onDown(key, event.eventTime, event.repeatCount)
-            KeyEvent.ACTION_UP -> engine.onUp(key, event.eventTime)
+        val key = HwKey.fromCode(event.keyCode)
+        val consumed = when {
+            !Channels.isEnabled(this, Channel.ACCESSIBILITY) -> false
+            key == null || !key.interceptable -> false
+            event.action == KeyEvent.ACTION_DOWN ->
+                engine.onDown(key, event.eventTime, event.repeatCount)
+            event.action == KeyEvent.ACTION_UP -> engine.onUp(key, event.eventTime)
             else -> false
         }
+        // Every event, not just the ones we act on: the tester screen is the
+        // only way to find out whether a key reaches a filter on this firmware.
+        KeyLog.record(event, key?.label ?: ("Key " + event.keyCode), consumed)
+        return consumed
     }
 
     override fun onAccessibilityEvent(event: AccessibilityEvent?) { /* not used */ }
diff --git a/app/src/main/java/dev/equwal/assistkey/engine/KeyLog.kt b/app/src/main/java/dev/equwal/assistkey/engine/KeyLog.kt
new file mode 100644
index 0000000..1014a58
--- /dev/null
+++ b/app/src/main/java/dev/equwal/assistkey/engine/KeyLog.kt
@@ -0,0 +1,65 @@
+package dev.equwal.assistkey.engine
+
+import android.view.KeyEvent
+
+/**
+ * A short record of what the key filter actually saw.
+ *
+ * Worth having permanently rather than as a debugging aid: whether a given key
+ * reaches an input filter at all is a per-firmware question with no way to look
+ * it up. Keys consumed upstream by the window manager - Power everywhere, and
+ * on some builds an OEM key too - simply never appear here, and that absence is
+ * the answer.
+ *
+ * Injected events (adb shell input keyevent) bypass accessibility input filters
+ * entirely, so only a real press proves anything.
+ */
+object KeyLog {
+
+    data class Entry(
+        val at: Long,
+        val keyCode: Int,
+        val action: Int,
+        val repeat: Int,
+        val label: String,
+        val consumed: Boolean
+    ) {
+        fun describe(): String {
+            val act = when (action) {
+                KeyEvent.ACTION_DOWN -> if (repeat > 0) "down x" + repeat else "down"
+                KeyEvent.ACTION_UP -> "up"
+                else -> "action " + action
+            }
+            return label + "  " + act + "  " + (if (consumed) "consumed" else "passed on")
+        }
+    }
+
+    private const val CAPACITY = 60
+
+    private val entries = ArrayDeque<Entry>(CAPACITY)
+
+    @Synchronized
+    fun record(event: KeyEvent, label: String, consumed: Boolean) {
+        if (entries.size >= CAPACITY) entries.removeFirst()
+        entries.addLast(
+            Entry(
+                at = System.currentTimeMillis(),
+                keyCode = event.keyCode,
+                action = event.action,
+                repeat = event.repeatCount,
+                label = label,
+                consumed = consumed
+            )
+        )
+    }
+
+    /** Newest first, which is the order the tester screen wants. */
+    @Synchronized
+    fun recent(): List<Entry> = entries.reversed()
+
+    @Synchronized
+    fun clear() = entries.clear()
+
+    @Synchronized
+    fun count(): Int = entries.size
+}
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/KeyTesterActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/KeyTesterActivity.kt
new file mode 100644
index 0000000..ba41d25
--- /dev/null
+++ b/app/src/main/java/dev/equwal/assistkey/ui/KeyTesterActivity.kt
@@ -0,0 +1,92 @@
+package dev.equwal.assistkey.ui
+
+import android.app.Activity
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.widget.LinearLayout
+import dev.equwal.assistkey.channel.Channel
+import dev.equwal.assistkey.channel.Channels
+import dev.equwal.assistkey.engine.KeyLog
+import dev.equwal.assistkey.ui.Ui.button
+import dev.equwal.assistkey.ui.Ui.header
+import dev.equwal.assistkey.ui.Ui.note
+import dev.equwal.assistkey.ui.Ui.row
+import dev.equwal.assistkey.ui.Ui.title
+
+/**
+ * Shows every key event the filter receives.
+ *
+ * This is the only honest way to answer "can this key be remapped at all?" on a
+ * given device. A key the window manager consumes upstream never reaches any
+ * input filter and simply will not appear - that silence is the answer, and it
+ * is what Power looks like on every Android device.
+ */
+class KeyTesterActivity : Activity() {
+
+    private val handler = Handler(Looper.getMainLooper())
+    private var lastCount = -1
+
+    private val tick = object : Runnable {
+        override fun run() {
+            if (KeyLog.count() != lastCount) build()
+            handler.postDelayed(this, 400L)
+        }
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        KeyLog.clear()
+    }
+
+    override fun onResume() {
+        super.onResume()
+        build()
+        handler.postDelayed(tick, 400L)
+    }
+
+    override fun onPause() {
+        super.onPause()
+        handler.removeCallbacks(tick)
+    }
+
+    private fun build() {
+        lastCount = KeyLog.count()
+        val col = Ui.page(this)
+        col.title("Key tester")
+
+        if (!Channels.isSatisfied(this, Channel.ACCESSIBILITY)) {
+            col.note(
+                "The accessibility key filter is not running, so nothing will " +
+                    "appear here. Turn it on from the main screen first."
+            )
+            return
+        }
+
+        col.note(
+            "Press a key. Anything that reaches the filter is listed below, " +
+                "whether or not it has a binding. Power will never appear: the " +
+                "window manager consumes it before any app can see it."
+        )
+        col.note(
+            "Only real presses count. Events injected over adb bypass " +
+                "accessibility input filters and will not show up."
+        )
+
+        col.button("Clear") { KeyLog.clear(); build() }
+
+        val recent = KeyLog.recent()
+        col.header(if (recent.isEmpty()) "Nothing yet" else "Most recent first")
+        if (recent.isEmpty()) {
+            col.note(
+                "If a key never appears after pressing it, that key cannot be " +
+                    "remapped through the accessibility channel on this " +
+                    "firmware. Try the Viwoods native hooks instead."
+            )
+            return
+        }
+        recent.forEach { e ->
+            col.row(e.describe(), "keycode " + e.keyCode, enabled = false)
+        }
+    }
+}
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 55e2aac..a38a7ba 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt
@@ -68,7 +68,28 @@ class MainActivity : Activity() {
                 build()
             }
             col.row("Status: " + Channels.status(this, ch), null, enabled = on)
-            if (on && !ok) col.button("Set up " + ch.title.lowercase()) { claim(ch) }
+            if (on && !ok) {
+                col.button("Set up " + ch.title.lowercase()) { claim(ch) }
+                if (ch == Channel.ACCESSIBILITY) restrictedSettingsHint(col)
+            }
+        }
+    }
+
+    /**
+     * Sideloaded apps cannot be given accessibility access until the user
+     * clears Android's restricted-settings block, and the symptom is that the
+     * toggle appears to work and then quietly reverts. Nothing can detect this,
+     * so it gets called out wherever it would bite.
+     */
+    private fun restrictedSettingsHint(col: LinearLayout) {
+        col.note(
+            "If the switch turns itself back off, Android is blocking it " +
+                "because this app was installed outside an app store. Open " +
+                "App info, tap the three-dot menu, choose Allow restricted " +
+                "settings, then try again."
+        )
+        col.button("Open App info") {
+            Channels.safeStart(this, Channels.appInfoIntent(this))
         }
     }
 
@@ -145,6 +166,11 @@ class MainActivity : Activity() {
             else "Enable the Viwoods channel to use these"
         ) { startActivity(Intent(this, ViwoodsActivity::class.java)) }
 
+        col.row(
+            "Key tester",
+            "See exactly which keys reach the filter on this device"
+        ) { startActivity(Intent(this, KeyTesterActivity::class.java)) }
+
         val t = Store.timing(this)
         col.row(
             "Gesture timing",