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 09ac2ef42c0cfe4ac02cb643b0fc4f27fc112264
truex <truex@equwal.com>
2026-09-20 22:27:51 -0700

Fix: a Power press on a sleeping or locked device always wakes and unlocks it

While the app manages the Power button, the firmware reaction to a short press
is off. A press on a sleeping or locked device then had only the firmware's own
wake to rely on, and the press could also run the tap action (Back).

KeyFilterService now treats such a press as a wake and never as a gesture. It
tracks SCREEN_OFF and SCREEN_ON, because the firmware can set isInteractive
before the press reaches this process. It also sends KEYCODE_WAKEUP and
`wm dismiss-keyguard` through the shell, so the wake does not depend on the
firmware. A press within one second after the screen comes on is the press that
woke it, and is ignored.

Regression test on a device: tools/e2e/power-wake.sh.

 .../equwal/assistkey/engine/KeyFilterService.kt    | 57 ++++++++++++++++++----
 tools/e2e/power-wake.sh                            | 49 +++++++++++++++++++
 2 files changed, 96 insertions(+), 10 deletions(-)
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 9d0b5fd..bd87f80 100644
--- a/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt
+++ b/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt
@@ -46,7 +46,12 @@ class KeyFilterService : AccessibilityService(), GestureEngine.Host {
 
         Shell.onChange(shellChanged)
         Store.onBindingsChanged = { syncPower() }
-        registerReceiver(screenOn, android.content.IntentFilter(android.content.Intent.ACTION_SCREEN_ON))
+        registerReceiver(
+            screenState,
+            android.content.IntentFilter(android.content.Intent.ACTION_SCREEN_ON).apply {
+                addAction(android.content.Intent.ACTION_SCREEN_OFF)
+            }
+        )
         Shell.connect(this)
         syncPower()
     }
@@ -56,32 +61,61 @@ class KeyFilterService : AccessibilityService(), GestureEngine.Host {
     private val shellChanged: () -> Unit = { syncPower() }
     private var powerIgnoredUntilUp = false
 
-    /** A press that wakes the reader is not a gesture. */
-    private val screenOn = object : android.content.BroadcastReceiver() {
+    /**
+     * True from the moment the screen goes off until it is on again. The
+     * broadcast for "off" arrives before the device sleeps, so this is a better
+     * witness than PowerManager.isInteractive, which the firmware may already
+     * have flipped by the time a Power press reaches this process.
+     */
+    private var screenOff = false
+    private var wokeAt = 0L
+
+    private val screenState = object : android.content.BroadcastReceiver() {
         override fun onReceive(c: android.content.Context?, i: android.content.Intent?) {
+            if (i?.action == android.content.Intent.ACTION_SCREEN_OFF) {
+                screenOff = true
+            } else {
+                screenOff = false
+                wokeAt = SystemClock.uptimeMillis()
+                dev.equwal.assistkey.display.ExtraDim.reapply(this@KeyFilterService)
+            }
             powerIgnoredUntilUp = true
             if (::engine.isInitialized) engine.onCancel()
-            dev.equwal.assistkey.display.ExtraDim.reapply(this@KeyFilterService)
         }
     }
 
     fun syncPower() = PowerControl.sync(this, serviceRunning = ServiceHolder.service === this, ::onRawPower)
 
+    /**
+     * A Power press on a sleeping or locked device has one job: wake it and
+     * unlock it. It is never a gesture. The firmware normally wakes the device
+     * by itself, but the button is ours now, so this does not rely on that: the
+     * wake and the keyguard dismissal are sent through the shell as well.
+     */
     private fun onRawPower(down: Boolean, at: Long) {
         if (!::engine.isInitialized) return
-        if (down) {
-            val awake = getSystemService(android.os.PowerManager::class.java)?.isInteractive != false
-            powerIgnoredUntilUp = !awake
-            if (awake) engine.onDown(HwKey.POWER, at, 0)
-        } else {
+        if (!down) {
             if (powerIgnoredUntilUp) powerIgnoredUntilUp = false else engine.onUp(HwKey.POWER, at)
+            return
+        }
+        val asleep = screenOff ||
+            getSystemService(android.os.PowerManager::class.java)?.isInteractive == false
+        val locked = getSystemService(android.app.KeyguardManager::class.java)?.isKeyguardLocked == true
+        val justWoke = SystemClock.uptimeMillis() - wokeAt < WAKE_GRACE_MS
+        if (asleep || locked || justWoke) {
+            powerIgnoredUntilUp = true
+            engine.onCancel()
+            if (asleep || locked) Shell.run("input keyevent KEYCODE_WAKEUP; wm dismiss-keyguard")
+            return
         }
+        powerIgnoredUntilUp = false
+        engine.onDown(HwKey.POWER, at, 0)
     }
 
     private fun stopPower() {
         Shell.removeOnChange(shellChanged)
         Store.onBindingsChanged = null
-        runCatching { unregisterReceiver(screenOn) }
+        runCatching { unregisterReceiver(screenState) }
         ServiceHolder.service = null
         syncPower() // with no service running this hands Power back to the firmware
     }
@@ -220,6 +254,9 @@ class KeyFilterService : AccessibilityService(), GestureEngine.Host {
         /** How long after a Power hold a key press still counts as a combination. */
         const val POWER_COMBO_WINDOW_MS = 1000L
 
+        /** A press this soon after the screen came on is the press that woke it. */
+        private const val WAKE_GRACE_MS = 1000L
+
         private val LIFELINE = setOf(
             GlobalAction.BACK.name, GlobalAction.HOME.name, GlobalAction.RECENTS.name
         )
diff --git a/tools/e2e/power-wake.sh b/tools/e2e/power-wake.sh
new file mode 100644
index 0000000..8508ea1
--- /dev/null
+++ b/tools/e2e/power-wake.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+# Regression test, on a device: in managed Power mode a Power press on a sleeping
+# device wakes it, and is NOT read as a gesture.
+#
+# Bug it guards: with tap bound to Back, the press that woke the reader could
+# also run Back, and a locked or charging reader had no sure way to wake.
+#
+# Needs: adb root, release build installed, key remapping on, shell access on,
+# Power tap bound to Back and triple tap to Lock screen (Navigation > Power key). Presses go through the kernel input
+# node, because `adb shell input keyevent` skips the input filter stage.
+set -u
+export MSYS_NO_PATHCONV=1
+NODE=${POWER_NODE:-/dev/input/event1}
+E="sendevent $NODE"; TAP="$E 1 116 1; $E 0 0 0; sleep 0.06; $E 1 116 0; $E 0 0 0"
+wake()  { adb shell dumpsys power | grep -oE 'mWakefulness=[A-Za-z]+' | head -1 | cut -d= -f2; }
+top()   { # dumpsys now and then answers before the activity has settled; ask again.
+  local t="" i
+  for i in 1 2 3 4 5; do
+    t=$(adb shell "dumpsys activity activities" | grep -m1 topResumedActivity | grep -oE '[A-Za-z0-9_.]+/[A-Za-z0-9_.$]+' | head -1)
+    [ -n "$t" ] && break; sleep 1
+  done; echo "$t"; }
+fail=0
+check() { if [ "$2" = "$3" ]; then echo "PASS  $1: $2"; else echo "FAIL  $1: got '$2', want '$3'"; fail=1; fi; }
+
+[ "$(adb shell settings get global power_button_short_press | tr -d '\r')" = "0" ] || { echo "SKIP  Power is not managed (power_button_short_press is not 0)"; exit 2; }
+
+adb shell input keyevent KEYCODE_WAKEUP; sleep 2   # let any earlier tap window run out
+# Two AssistKey screens deep, so that Back would visibly change the top activity.
+# (Not the Settings app: on the Viwoods reader an injected SLEEP key is ignored
+# while Settings > Display is in front.)
+P=dev.equwal.assistkey
+adb shell "am start -n $P/.ui.MainActivity" >/dev/null 2>&1; sleep 2
+adb shell "su 0 am start -n $P/.ui.TimingActivity" >/dev/null 2>&1; sleep 2.5
+before=$(top)
+
+# Sleep with the triple tap the app binds to Lock screen. An injected SLEEP key
+# is not reliable on this firmware.
+QUICK="$E 1 116 1; $E 0 0 0; $E 1 116 0; $E 0 0 0;"
+adb shell "su 0 sh -c '$QUICK sleep 0.05; $QUICK sleep 0.05; $QUICK'"; sleep 4
+check "device sleeps" "$(wake | sed 's/Dozing/Asleep/')" "Asleep"
+
+adb shell "su 0 sh -c '$TAP'"; sleep 3
+check "one Power press wakes it" "$(wake)" "Awake"
+check "the waking press is not a Back gesture" "$(top)" "$before"
+
+adb shell "su 0 sh -c '$TAP'"; sleep 2.5
+[ "$(top)" != "$before" ] && echo "PASS  the next press is a gesture again" || { echo "FAIL  a press while awake did nothing"; fail=1; }
+adb shell input keyevent KEYCODE_HOME
+exit $fail