commit 1030d2b2c355341a737cc75a6d72dc054e70456f truex <truex@equwal.com> 2026-09-20 22:33:14 -0700 Viwoods: inside the AI screen or the crop screen, the AI key goes back The key filter now reads window changes to know which app was in use before an AI screen opened. It keeps the latest package name only, in memory only. When the AI key is pressed inside the Viwoods AI assistant, or inside the crop and screenshot-edit screens, the app brings that earlier app back with the same intent a launcher sends. If the earlier app was a home screen, it goes Home. The firmware reacts to the AI key by itself and can open its AI screen again, so the return waits 350 ms for that to land first. The same rule applies when the firmware hook points at AiKeyActivity. A switch on the AI key screen turns the rule off. It is on by default on Viwoods devices and absent on others. Test on a device: tools/e2e/ai-key-return.sh.
.../dev/equwal/assistkey/channel/AiKeyActivity.kt | 3 + .../java/dev/equwal/assistkey/device/Device.kt | 17 +++++ .../equwal/assistkey/engine/KeyFilterService.kt | 76 +++++++++++++++++++++- .../dev/equwal/assistkey/ui/TriggerListActivity.kt | 8 +++ tools/e2e/ai-key-return.sh | 36 ++++++++++ 5 files changed, 139 insertions(+), 1 deletion(-)
diff --git a/app/src/main/java/dev/equwal/assistkey/channel/AiKeyActivity.kt b/app/src/main/java/dev/equwal/assistkey/channel/AiKeyActivity.kt index 512d501..8400ee5 100644 --- a/app/src/main/java/dev/equwal/assistkey/channel/AiKeyActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/channel/AiKeyActivity.kt @@ -41,6 +41,9 @@ class AiKeyActivity : Activity() { val svc = ServiceHolder.service as? KeyFilterService when { + // Inside an AI screen the key means "back to what I was doing". + svc != null && svc.inAiScreen && License.active(this) && + dev.equwal.assistkey.device.Device.aiKeyReturns(this) -> svc.returnFromAiScreen() // Ahead of the licence check: the navigation lifeline lives here. svc != null && svc.takePowerCombo(HwKey.AI) -> Unit // Locked means the key behaves as the firmware intended. 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 f395775..6091343 100644 --- a/app/src/main/java/dev/equwal/assistkey/device/Device.kt +++ b/app/src/main/java/dev/equwal/assistkey/device/Device.kt @@ -49,6 +49,23 @@ object Device { val brightnessFloor: Int? get() = if (isViwoods) 5 else null private const val PREFS = "assistkey_device" + private const val K_AI_RETURNS = "ai_key_returns" + + /** + * Viwoods only. Inside the AI screen or the crop screen, the AI key goes + * back to the app that was in use before. On by default on that device. + */ + fun aiKeyReturns(c: Context): Boolean = + isViwoods && c.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(K_AI_RETURNS, true) + + fun setAiKeyReturns(c: Context, on: Boolean) { + c.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(K_AI_RETURNS, 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" || + (pkg == "com.viwoods.launcher" && cls?.contains(".libfloating.") == true) private const val K_SEEN = "seen_keys" private val builtIn: List<HwKey> 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 bd87f80..c77a85b 100644 --- a/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt +++ b/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt @@ -200,6 +200,7 @@ class KeyFilterService : AccessibilityService(), GestureEngine.Host { } val consumed = when { !Channels.isEnabled(this, Channel.ACCESSIBILITY) -> false + key != null && License.active(this) && aiKeyReturn(event, key) -> true // Ahead of the licence check on purpose - see armPowerCombo. key != null && key.interceptable && powerCombo(event, key) -> true // Locked means inert, not broken: every key goes to the firmware. @@ -222,7 +223,77 @@ class KeyFilterService : AccessibilityService(), GestureEngine.Host { return consumed } - override fun onAccessibilityEvent(event: AccessibilityEvent?) { /* not used */ } + // ---- the AI key, inside an AI screen ------------------------------------------ + + /** True while the Viwoods AI screen or crop screen is in front. */ + var inAiScreen = false + private set + + /** The package in front before the AI screen opened. Held in memory only. */ + private var lastApp: String? = null + private val aiSwallowed = HashSet<HwKey>() + + /** + * Window changes are watched for one purpose: to know which app to go back + * to. Only the package name is kept, only the latest one, and only in + * memory. + */ + override fun onAccessibilityEvent(event: AccessibilityEvent?) { + if (event?.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) return + val pkg = event.packageName?.toString() ?: return + val cls = event.className?.toString() + when { + 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. + cls != null && isActivity(pkg, cls) -> { + inAiScreen = false + lastApp = pkg + } + } + } + + private fun isActivity(pkg: String, cls: String): Boolean = runCatching { + packageManager.getActivityInfo(android.content.ComponentName(pkg, cls), 0); true + }.getOrDefault(false) + + /** + * Leaves the AI screen for the app that was in use before it. The firmware + * reacts to the AI key by itself and may open its AI screen again, so this + * waits for that to land first. + */ + fun returnFromAiScreen() { + handler.postDelayed({ + val pkg = lastApp + val launch = pkg?.let { packageManager.getLaunchIntentForPackage(it) } + val isHome = pkg != null && packageManager.resolveActivity( + android.content.Intent(android.content.Intent.ACTION_MAIN) + .addCategory(android.content.Intent.CATEGORY_HOME), + 0 + )?.activityInfo?.packageName == pkg + if (launch == null || isHome) { + performGlobalAction(GLOBAL_ACTION_HOME) + } else { + // The same intent the launcher sends: it brings the task back as it was. + startActivity( + launch.addFlags( + android.content.Intent.FLAG_ACTIVITY_NEW_TASK or + android.content.Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED + ) + ) + } + }, AI_RETURN_DELAY_MS) + } + + private fun aiKeyReturn(event: KeyEvent, key: HwKey): Boolean { + if (key != HwKey.AI) return false + if (event.action == KeyEvent.ACTION_UP) return aiSwallowed.remove(key) + if (key in aiSwallowed) return true + if (!inAiScreen || !dev.equwal.assistkey.device.Device.aiKeyReturns(this)) return false + aiSwallowed.add(key) + returnFromAiScreen() + return true + } /** * The settings screen lives in this same process, so new timing values can @@ -254,6 +325,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 + /** Longer than the firmware takes to open its AI screen on an AI key press. */ + private const val AI_RETURN_DELAY_MS = 350L + /** A press this soon after the screen came on is the press that woke it. */ private const val WAKE_GRACE_MS = 1000L diff --git a/app/src/main/java/dev/equwal/assistkey/ui/TriggerListActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/TriggerListActivity.kt index 7c9a9ee..df35ef8 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/TriggerListActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/TriggerListActivity.kt @@ -11,6 +11,7 @@ import dev.equwal.assistkey.model.HwKey import dev.equwal.assistkey.model.Trigger import dev.equwal.assistkey.native.ViwoodsBridge import dev.equwal.assistkey.store.Store +import dev.equwal.assistkey.ui.Ui.check import dev.equwal.assistkey.ui.Ui.header import dev.equwal.assistkey.ui.Ui.note import dev.equwal.assistkey.ui.Ui.row @@ -54,6 +55,13 @@ class TriggerListActivity : Activity() { ) } + if (keys == setOf(HwKey.AI) && dev.equwal.assistkey.device.Device.isViwoods) { + col.check( + "In the AI or crop screen, go back", + "There the AI key returns to the app you were in, whatever is bound below", + dev.equwal.assistkey.device.Device.aiKeyReturns(this) + ) { dev.equwal.assistkey.device.Device.setAiKeyReturns(this, it) } + } if (HwKey.AI in keys) { col.note( if (ViwoodsBridge.aiHookedToUs(this)) { diff --git a/tools/e2e/ai-key-return.sh b/tools/e2e/ai-key-return.sh new file mode 100644 index 0000000..0a7493c --- /dev/null +++ b/tools/e2e/ai-key-return.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Test on a Viwoods reader: inside the AI screen or the crop screen, the AI key +# goes back to the app that was in use before. +# +# Needs: adb root, release build installed, key remapping on. 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=${AI_NODE:-/dev/input/event5} +E="sendevent $NODE"; AI="$E 1 59 1; $E 0 0 0; sleep 0.08; $E 1 59 0; $E 0 0 0" +top() { + 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 input keyevent KEYCODE_WAKEUP; sleep 2 +adb shell "am start -a android.settings.SETTINGS" >/dev/null 2>&1; sleep 3 +check "start in a normal app" "$(top)" "com.android.settings" + +adb shell "am start -n com.viwoods.viwoodsai/com.wisky.wiskyai.WebViewAiActivity" >/dev/null 2>&1; sleep 3 +check "AI screen is in front" "$(top)" "com.viwoods.viwoodsai" +adb shell "su 0 sh -c '$AI'"; sleep 3 +check "AI key in the AI screen returns to the app" "$(top)" "com.android.settings" + +adb shell "su 0 am start -n com.viwoods.launcher/com.viwoods.libfloating.activity.ScreenCaptureActivity" >/dev/null 2>&1; sleep 4 +check "crop screen is in front" "$(top)" "com.viwoods.launcher" +adb shell "su 0 sh -c '$AI'"; sleep 3 +check "AI key in the crop screen returns to the app" "$(top)" "com.android.settings" + +adb shell input keyevent KEYCODE_HOME +exit $fail