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