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 4ee7a575fb8d12ea6d25b82f08f4a723d7db365c
truex <truex@equwal.com>
2026-09-19 21:40:14 -0700

wip

 .gitignore                                         |  25 +++
 app/build.gradle.kts                               |  39 +++++
 app/src/main/AndroidManifest.xml                   |  62 +++++++
 .../dev/equwal/assistkey/engine/GestureEngine.kt   | 194 +++++++++++++++++++++
 .../java/dev/equwal/assistkey/model/ActionSpec.kt  |  79 +++++++++
 .../dev/equwal/assistkey/model/GlobalAction.kt     |  33 ++++
 .../main/java/dev/equwal/assistkey/model/HwKey.kt  |  36 ++++
 .../java/dev/equwal/assistkey/model/Presets.kt     |  34 ++++
 .../java/dev/equwal/assistkey/model/Trigger.kt     |  70 ++++++++
 .../dev/equwal/assistkey/route/ActionRouter.kt     | 172 ++++++++++++++++++
 .../dev/equwal/assistkey/route/ServiceHolder.kt    |  15 ++
 .../java/dev/equwal/assistkey/store/Bindings.kt    |  56 ++++++
 .../main/java/dev/equwal/assistkey/store/Store.kt  |  74 ++++++++
 app/src/main/res/values/strings.xml                |  28 +++
 app/src/main/res/values/styles.xml                 |  14 ++
 .../main/res/xml/accessibility_service_config.xml  |  13 ++
 build.gradle.kts                                   |   5 +
 gradle.properties                                  |   6 +
 settings.gradle.kts                                |  24 +++
 19 files changed, 979 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5586b55
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,25 @@
+# Build output
+build/
+app/build/
+.gradle/
+*.apk
+*.aab
+*.ap_
+*.dex
+
+# Local toolchain paths - machine specific
+local.properties
+
+# IDE
+.idea/
+*.iml
+.vscode/
+
+# Signing material must never be committed
+*.jks
+*.keystore
+keystore.properties
+
+# OS
+Thumbs.db
+.DS_Store
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..fc05836
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,39 @@
+plugins {
+    id("com.android.application")
+    id("org.jetbrains.kotlin.android")
+}
+
+android {
+    namespace = "dev.equwal.assistkey"
+    compileSdk = 36
+
+    defaultConfig {
+        applicationId = "dev.equwal.assistkey"
+        // 30 is the floor for GLOBAL_ACTION_TAKE_SCREENSHOT.
+        minSdk = 30
+        targetSdk = 36
+        versionCode = 1
+        versionName = "1.0"
+    }
+
+    buildTypes {
+        release {
+            // No shrinking: the app is tiny and R8 would only complicate
+            // keeping the accessibility service and assist activity reachable.
+            isMinifyEnabled = false
+        }
+    }
+
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_17
+        targetCompatibility = JavaVersion.VERSION_17
+    }
+
+    kotlinOptions {
+        jvmTarget = "17"
+    }
+}
+
+// Deliberately no dependencies - framework APIs only, so the APK stays a few KB
+// and there is nothing to audit but our own code.
+dependencies { }
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..ededa44
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <application
+        android:allowBackup="true"
+        android:icon="@android:drawable/ic_menu_preferences"
+        android:label="@string/app_name"
+        android:supportsRtl="true"
+        android:theme="@android:style/Theme.DeviceDefault.Light">
+
+        <!-- Config UI -->
+        <activity
+            android:name=".MainActivity"
+            android:exported="true"
+            android:label="@string/app_name">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+
+        <!--
+            This is the whole trick. Long-pressing power fires ACTION_ASSIST at
+            whichever app holds the assistant role. We claim it with an invisible,
+            no-history activity that fires the action and immediately finishes,
+            so nothing is ever drawn on screen.
+        -->
+        <activity
+            android:name=".AssistActivity"
+            android:exported="true"
+            android:excludeFromRecents="true"
+            android:noHistory="true"
+            android:launchMode="singleInstance"
+            android:taskAffinity=""
+            android:theme="@style/Theme.Invisible">
+            <intent-filter>
+                <action android:name="android.intent.action.ASSIST" />
+                <category android:name="android.intent.category.DEFAULT" />
+            </intent-filter>
+            <intent-filter>
+                <action android:name="android.intent.action.VOICE_COMMAND" />
+                <category android:name="android.intent.category.DEFAULT" />
+            </intent-filter>
+        </activity>
+
+        <!-- Back and Recents are only reachable through global accessibility actions. -->
+        <service
+            android:name=".KeyAccessibilityService"
+            android:exported="false"
+            android:label="@string/service_label"
+            android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
+            <intent-filter>
+                <action android:name="android.accessibilityservice.AccessibilityService" />
+            </intent-filter>
+            <meta-data
+                android:name="android.accessibilityservice"
+                android:resource="@xml/accessibility_service_config" />
+        </service>
+
+    </application>
+
+</manifest>
diff --git a/app/src/main/java/dev/equwal/assistkey/engine/GestureEngine.kt b/app/src/main/java/dev/equwal/assistkey/engine/GestureEngine.kt
new file mode 100644
index 0000000..20da1d7
--- /dev/null
+++ b/app/src/main/java/dev/equwal/assistkey/engine/GestureEngine.kt
@@ -0,0 +1,194 @@
+package dev.equwal.assistkey.engine
+
+import dev.equwal.assistkey.model.GestureType
+import dev.equwal.assistkey.model.HwKey
+import dev.equwal.assistkey.model.Trigger
+
+/**
+ * Turns a stream of key down/up events into tap-count / hold / chord gestures.
+ *
+ * Deliberately free of Android types: everything that touches the framework
+ * (timers, firing an action, emulating the native key) goes through [Host], so
+ * the whole state machine is unit-testable on the JVM.
+ *
+ * The one rule that matters for feel: a key with no bindings at all is never
+ * consumed, and a key whose highest bound tap count is 1 fires on key-up
+ * without waiting out the multi-tap window. We only pay the multi-tap latency
+ * on keys where the user actually asked for a double/triple tap.
+ */
+class GestureEngine(
+    private val host: Host,
+    private val cfg: Config = Config()
+) {
+
+    data class Config(
+        /** How long to wait for another tap before committing the count. */
+        val multiTapMs: Long = 280,
+        /** How long a key must stay down to count as a hold. */
+        val holdMs: Long = 450,
+        /** Two keys going down within this window form a chord. */
+        val chordMs: Long = 140
+    )
+
+    interface Host {
+        /** Highest bound tap count for this key set, or 0 if no tap is bound. */
+        fun maxTaps(keys: Set<HwKey>): Int
+
+        /** True when a hold is bound for this key set. */
+        fun hasHold(keys: Set<HwKey>): Boolean
+
+        /** True when this exact gesture has a binding. */
+        fun isBound(trigger: Trigger): Boolean
+
+        /** Any key set that starts with this key and could still become a chord. */
+        fun chordPartners(key: HwKey): Set<HwKey>
+
+        fun fire(trigger: Trigger)
+
+        /** Reproduce what the system would have done, since we ate the event. */
+        fun passThrough(keys: Set<HwKey>, longPress: Boolean)
+
+        fun schedule(token: String, delayMs: Long, action: () -> Unit)
+        fun cancel(token: String)
+    }
+
+    private companion object {
+        const val T_HOLD = "hold"
+        const val T_TAP = "tap"
+    }
+
+    private val down = LinkedHashSet<HwKey>()
+    private var seqKeys: Set<HwKey> = emptySet()
+    private var tapCount = 0
+    private var holdFired = false
+    private var consuming = false
+    private var firstDownAt = 0L
+
+    /** @return true to swallow the event. */
+    fun onDown(key: HwKey, now: Long, repeatCount: Int): Boolean {
+        if (repeatCount > 0) return consuming   // our own timer drives holds
+
+        down.add(key)
+
+        val chordUpgrade = consuming &&
+            key !in seqKeys &&
+            (now - firstDownAt) <= cfg.chordMs &&
+            interesting(seqKeys + key)
+
+        when {
+            chordUpgrade -> {
+                host.cancel(T_HOLD); host.cancel(T_TAP)
+                seqKeys = seqKeys + key
+                tapCount = 0
+                holdFired = false
+            }
+            !consuming || key !in seqKeys -> {
+                host.cancel(T_HOLD); host.cancel(T_TAP)
+                seqKeys = setOf(key)
+                tapCount = 0
+                holdFired = false
+                firstDownAt = now
+            }
+            else -> {
+                // Another tap of a sequence already in progress.
+                host.cancel(T_TAP)
+            }
+        }
+
+        // Only grab the key if this set, or a chord it could grow into, is bound.
+        if (!interesting(seqKeys) && !couldBecomeChord(seqKeys, now)) {
+            reset()
+            return false
+        }
+
+        consuming = true
+        if (host.hasHold(seqKeys)) {
+            host.schedule(T_HOLD, cfg.holdMs) { onHoldElapsed() }
+        }
+        return true
+    }
+
+    /** @return true to swallow the event. */
+    fun onUp(key: HwKey, now: Long): Boolean {
+        down.remove(key)
+        if (!consuming) return false
+
+        host.cancel(T_HOLD)
+
+        if (holdFired) {
+            if (down.isEmpty()) reset()
+            return true
+        }
+
+        // For a chord, wait until every key of the chord is released.
+        if (down.any { it in seqKeys }) return true
+
+        tapCount++
+        val max = host.maxTaps(seqKeys)
+
+        if (max == 0) {
+            // Nothing bound to taps - only a hold was. Give the key back.
+            val keys = seqKeys
+            reset()
+            host.passThrough(keys, longPress = false)
+            return true
+        }
+
+        if (tapCount >= max) {
+            commit()
+        } else {
+            host.schedule(T_TAP, cfg.multiTapMs) { commit() }
+        }
+        return true
+    }
+
+    fun onCancel() {
+        host.cancel(T_HOLD); host.cancel(T_TAP)
+        down.clear()
+        reset()
+    }
+
+    private fun onHoldElapsed() {
+        val t = Trigger(seqKeys, GestureType.HOLD)
+        holdFired = true
+        tapCount = 0
+        if (host.isBound(t)) host.fire(t)
+    }
+
+    private fun commit() {
+        host.cancel(T_TAP)
+        val keys = seqKeys
+        val n = tapCount.coerceIn(1, Trigger.MAX_TAPS)
+        reset()
+        if (keys.isEmpty()) return
+
+        val t = Trigger(keys, GestureType.TAP, n)
+        if (host.isBound(t)) {
+            host.fire(t)
+        } else if (n == 1) {
+            host.passThrough(keys, longPress = false)
+        }
+        // A 2+ tap with no binding is intentionally dropped rather than
+        // replayed - replaying would fire the native action N times.
+    }
+
+    private fun interesting(keys: Set<HwKey>): Boolean =
+        host.maxTaps(keys) > 0 || host.hasHold(keys)
+
+    /**
+     * A lone key with no bindings still has to be held briefly if it is the
+     * first half of a bound chord, otherwise the chord could never be entered.
+     */
+    private fun couldBecomeChord(keys: Set<HwKey>, now: Long): Boolean {
+        if (keys.size != 1) return false
+        val partners = host.chordPartners(keys.first())
+        return partners.isNotEmpty() && (now - firstDownAt) <= cfg.chordMs
+    }
+
+    private fun reset() {
+        seqKeys = emptySet()
+        tapCount = 0
+        holdFired = false
+        consuming = false
+    }
+}
diff --git a/app/src/main/java/dev/equwal/assistkey/model/ActionSpec.kt b/app/src/main/java/dev/equwal/assistkey/model/ActionSpec.kt
new file mode 100644
index 0000000..fdc4faa
--- /dev/null
+++ b/app/src/main/java/dev/equwal/assistkey/model/ActionSpec.kt
@@ -0,0 +1,79 @@
+package dev.equwal.assistkey.model
+
+import org.json.JSONObject
+
+/**
+ * What a trigger does. [payload] is interpreted per [kind]; keeping it a plain
+ * string keeps persistence trivial and lets the action set grow without a
+ * schema migration.
+ */
+enum class ActionKind {
+    /** Swallow the key and do nothing - this is how a key gets disabled. */
+    NONE,
+
+    /** Do not intercept; let the system handle the key natively. */
+    PASS_THROUGH,
+
+    /** AccessibilityService.performGlobalAction; payload = GlobalAction.name */
+    GLOBAL,
+
+    /** payload = one of raise|lower|mute|panel */
+    VOLUME,
+
+    /** payload = one of play_pause|next|previous|stop */
+    MEDIA,
+
+    /** payload = package name; launches its main activity */
+    LAUNCH_APP,
+
+    /** payload = "pkg/class"; explicit component, no intent-filter needed */
+    LAUNCH_COMPONENT,
+
+    /** payload = intent action string; sent as an implicit activity intent */
+    LAUNCH_ACTION,
+
+    /** payload = intent action string; sent as a broadcast */
+    BROADCAST,
+
+    /** payload = up|down|left|right; dispatches a synthetic swipe */
+    SWIPE,
+
+    /** payload = forward|backward; scrolls the focused scrollable node */
+    SCROLL;
+
+    companion object {
+        fun fromName(n: String): ActionKind =
+            entries.firstOrNull { it.name == n } ?: NONE
+    }
+}
+
+data class ActionSpec(
+    val kind: ActionKind,
+    val payload: String = "",
+    /** Cached display label so the UI need not resolve packages on every draw. */
+    val label: String = ""
+) {
+    fun toJson(): JSONObject = JSONObject()
+        .put("kind", kind.name)
+        .put("payload", payload)
+        .put("label", label)
+
+    fun describe(): String = label.ifBlank {
+        when (kind) {
+            ActionKind.NONE -> "Disabled"
+            ActionKind.PASS_THROUGH -> "Default behaviour"
+            else -> if (payload.isBlank()) kind.name else "${kind.name}: $payload"
+        }
+    }
+
+    companion object {
+        val NOTHING = ActionSpec(ActionKind.NONE, label = "Disabled")
+        val PASS = ActionSpec(ActionKind.PASS_THROUGH, label = "Default behaviour")
+
+        fun fromJson(o: JSONObject): ActionSpec = ActionSpec(
+            kind = ActionKind.fromName(o.optString("kind", "NONE")),
+            payload = o.optString("payload", ""),
+            label = o.optString("label", "")
+        )
+    }
+}
diff --git a/app/src/main/java/dev/equwal/assistkey/model/GlobalAction.kt b/app/src/main/java/dev/equwal/assistkey/model/GlobalAction.kt
new file mode 100644
index 0000000..af542ea
--- /dev/null
+++ b/app/src/main/java/dev/equwal/assistkey/model/GlobalAction.kt
@@ -0,0 +1,33 @@
+package dev.equwal.assistkey.model
+
+import android.accessibilityservice.AccessibilityService as AS
+import android.os.Build
+
+/**
+ * The AccessibilityService global actions worth exposing, with the SDK level
+ * each one needs. Anything above the running SDK is filtered out of the picker
+ * rather than failing at press time.
+ */
+enum class GlobalAction(val id: Int, val label: String, val minSdk: Int = 30) {
+    BACK(AS.GLOBAL_ACTION_BACK, "Back"),
+    HOME(AS.GLOBAL_ACTION_HOME, "Home"),
+    RECENTS(AS.GLOBAL_ACTION_RECENTS, "Recent apps"),
+    NOTIFICATIONS(AS.GLOBAL_ACTION_NOTIFICATIONS, "Notification shade"),
+    QUICK_SETTINGS(AS.GLOBAL_ACTION_QUICK_SETTINGS, "Quick settings"),
+    POWER_DIALOG(AS.GLOBAL_ACTION_POWER_DIALOG, "Power menu"),
+    LOCK_SCREEN(AS.GLOBAL_ACTION_LOCK_SCREEN, "Lock screen"),
+    SCREENSHOT(AS.GLOBAL_ACTION_TAKE_SCREENSHOT, "Screenshot"),
+    DPAD_UP(16, "D-pad up", Build.VERSION_CODES.UPSIDE_DOWN_CAKE),
+    DPAD_DOWN(17, "D-pad down", Build.VERSION_CODES.UPSIDE_DOWN_CAKE),
+    DPAD_LEFT(18, "D-pad left", Build.VERSION_CODES.UPSIDE_DOWN_CAKE),
+    DPAD_RIGHT(19, "D-pad right", Build.VERSION_CODES.UPSIDE_DOWN_CAKE),
+    DPAD_CENTER(20, "D-pad centre", Build.VERSION_CODES.UPSIDE_DOWN_CAKE),
+    MEDIA_PLAY_PAUSE(21, "Play / pause", Build.VERSION_CODES.UPSIDE_DOWN_CAKE);
+
+    val available: Boolean get() = Build.VERSION.SDK_INT >= minSdk
+
+    companion object {
+        fun fromName(n: String): GlobalAction? = entries.firstOrNull { it.name == n }
+        fun usable(): List<GlobalAction> = entries.filter { it.available }
+    }
+}
diff --git a/app/src/main/java/dev/equwal/assistkey/model/HwKey.kt b/app/src/main/java/dev/equwal/assistkey/model/HwKey.kt
new file mode 100644
index 0000000..e061fc5
--- /dev/null
+++ b/app/src/main/java/dev/equwal/assistkey/model/HwKey.kt
@@ -0,0 +1,36 @@
+package dev.equwal.assistkey.model
+
+import android.view.KeyEvent
+
+/**
+ * The physical keys this app can bind.
+ *
+ * POWER is listed because it can carry actions, but it is deliberately not
+ * [interceptable]: PhoneWindowManager consumes KEYCODE_POWER in
+ * interceptKeyBeforeQueueing() before the event reaches the input dispatcher,
+ * so no AccessibilityService on an unrooted device ever sees it. Power is
+ * driven through the role channels and the native power_button_* settings
+ * instead - see dev.equwal.assistkey.channel.
+ */
+enum class HwKey(
+    val code: Int,
+    val label: String,
+    /** True when an AccessibilityService can see and consume this key. */
+    val interceptable: Boolean
+) {
+    AI(KeyEvent.KEYCODE_F1, "AI key", true),
+    VOL_UP(KeyEvent.KEYCODE_VOLUME_UP, "Volume up", true),
+    VOL_DOWN(KeyEvent.KEYCODE_VOLUME_DOWN, "Volume down", true),
+    POWER(KeyEvent.KEYCODE_POWER, "Power", false);
+
+    /** Stable token used in persisted trigger ids. Never change these. */
+    val token: String get() = name.lowercase()
+
+    companion object {
+        fun fromCode(code: Int): HwKey? = entries.firstOrNull { it.code == code }
+        fun fromToken(t: String): HwKey? = entries.firstOrNull { it.token == t }
+
+        /** The keys the accessibility engine is allowed to grab. */
+        val interceptable: List<HwKey> get() = entries.filter { it.interceptable }
+    }
+}
diff --git a/app/src/main/java/dev/equwal/assistkey/model/Presets.kt b/app/src/main/java/dev/equwal/assistkey/model/Presets.kt
new file mode 100644
index 0000000..e5f1388
--- /dev/null
+++ b/app/src/main/java/dev/equwal/assistkey/model/Presets.kt
@@ -0,0 +1,34 @@
+package dev.equwal.assistkey.model
+
+/**
+ * The stock Viwoods targets, recovered from the firmware manifests shipped on
+ * this device (ViwoodsLauncher 1.0.35 and viwoodsAi 2.1.52). These are what the
+ * OEM key settings screen binds to, so offering them here keeps parity with
+ * stock.
+ *
+ * ScreenCaptureActivity is the factory value of Settings.System.CustomAiKey.
+ */
+object Presets {
+
+    private const val LAUNCHER = "com.viwoods.launcher"
+    private const val AI = "com.viwoods.viwoodsai"
+
+    data class Preset(val label: String, val component: String) {
+        fun toSpec() = ActionSpec(ActionKind.LAUNCH_COMPONENT, component, label)
+    }
+
+    val viwoods: List<Preset> = listOf(
+        Preset("AI crop (screen capture)", LAUNCHER + "/com.viwoods.libfloating.activity.ScreenCaptureActivity"),
+        Preset("Edit screenshot", LAUNCHER + "/com.viwoods.libfloating.activity.EditScreenshotActivity"),
+        Preset("AI quick prompt (transparent)", AI + "/com.wisky.wiskyai.AiTransparentActivity"),
+        Preset("AI lookup", AI + "/com.wisky.wiskyai.AIActivity"),
+        Preset("AI assistant (full UI)", AI + "/com.wisky.wiskyai.WebViewAiActivity"),
+        Preset("AI history", AI + "/com.wisky.wiskyai.HistroyActivity"),
+        Preset("AI repository", AI + "/com.wisky.wiskyai.RepositoryActivity"),
+        Preset("Viwoods home", LAUNCHER + "/com.viwoods.launcher.main.LauncherMainActivity")
+    )
+
+    /** The factory AI-key binding, restored when the user resets. */
+    const val STOCK_AI_KEY =
+        "com.viwoods.launcher/com.viwoods.libfloating.activity.ScreenCaptureActivity"
+}
diff --git a/app/src/main/java/dev/equwal/assistkey/model/Trigger.kt b/app/src/main/java/dev/equwal/assistkey/model/Trigger.kt
new file mode 100644
index 0000000..1c1a6e9
--- /dev/null
+++ b/app/src/main/java/dev/equwal/assistkey/model/Trigger.kt
@@ -0,0 +1,70 @@
+package dev.equwal.assistkey.model
+
+/** Tap (possibly repeated) or a sustained hold. */
+enum class GestureType { TAP, HOLD }
+
+/**
+ * One bindable gesture: a set of keys pressed together (one key = a plain
+ * press, several = a chord) performed as N taps or as a hold.
+ *
+ * Serialised as "ai+vol_up:tap:2" - keys sorted by enum ordinal so the same
+ * chord always produces the same id regardless of press order.
+ */
+data class Trigger(
+    val keys: Set<HwKey>,
+    val type: GestureType,
+    val count: Int = 1
+) {
+    init {
+        require(keys.isNotEmpty()) { "trigger needs at least one key" }
+        require(count in 1..MAX_TAPS) { "count out of range: $count" }
+        require(type == GestureType.TAP || count == 1) { "hold cannot repeat" }
+    }
+
+    val isChord: Boolean get() = keys.size > 1
+
+    val id: String
+        get() = keys.sortedBy { it.ordinal }.joinToString("+") { it.token } +
+            ":" + type.name.lowercase() + ":" + count
+
+    /** Human label, e.g. "AI key + Volume up - double tap". */
+    fun label(): String {
+        val k = keys.sortedBy { it.ordinal }.joinToString(" + ") { it.label }
+        val g = when {
+            type == GestureType.HOLD -> "hold"
+            count == 1 -> "tap"
+            count == 2 -> "double tap"
+            count == 3 -> "triple tap"
+            else -> "$count taps"
+        }
+        return "$k — $g"
+    }
+
+    companion object {
+        const val MAX_TAPS = 5
+
+        fun parse(id: String): Trigger? {
+            val parts = id.split(":")
+            if (parts.size != 3) return null
+            val keys = parts[0].split("+").mapNotNull { HwKey.fromToken(it) }.toSet()
+            if (keys.isEmpty()) return null
+            val type = when (parts[1]) {
+                "tap" -> GestureType.TAP
+                "hold" -> GestureType.HOLD
+                else -> return null
+            }
+            val count = parts[2].toIntOrNull() ?: return null
+            if (count !in 1..MAX_TAPS) return null
+            if (type == GestureType.HOLD && count != 1) return null
+            return Trigger(keys, type, count)
+        }
+
+        /**
+         * Every gesture the UI offers for a given key set: taps 1..MAX_TAPS
+         * plus a hold.
+         */
+        fun allFor(keys: Set<HwKey>): List<Trigger> =
+            (1..MAX_TAPS).map { Trigger(keys, GestureType.TAP, it) } +
+                Trigger(keys, GestureType.HOLD)
+    }
+}
diff --git a/app/src/main/java/dev/equwal/assistkey/route/ActionRouter.kt b/app/src/main/java/dev/equwal/assistkey/route/ActionRouter.kt
new file mode 100644
index 0000000..eb5225f
--- /dev/null
+++ b/app/src/main/java/dev/equwal/assistkey/route/ActionRouter.kt
@@ -0,0 +1,172 @@
+package dev.equwal.assistkey.route
+
+import android.accessibilityservice.GestureDescription
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.graphics.Path
+import android.media.AudioManager
+import android.util.Log
+import android.view.KeyEvent
+import android.view.accessibility.AccessibilityNodeInfo
+import dev.equwal.assistkey.model.ActionKind
+import dev.equwal.assistkey.model.ActionSpec
+import dev.equwal.assistkey.model.GlobalAction
+
+/** Executes an [ActionSpec]. The single place any binding turns into behaviour. */
+object ActionRouter {
+
+    private const val TAG = "AssistKey"
+
+    fun run(ctx: Context, spec: ActionSpec): Boolean {
+        return try {
+            when (spec.kind) {
+                ActionKind.NONE -> true
+                ActionKind.PASS_THROUGH -> false
+                ActionKind.GLOBAL -> global(spec.payload)
+                ActionKind.VOLUME -> volume(ctx, spec.payload)
+                ActionKind.MEDIA -> media(ctx, spec.payload)
+                ActionKind.LAUNCH_APP -> launchApp(ctx, spec.payload)
+                ActionKind.LAUNCH_COMPONENT -> launchComponent(ctx, spec.payload)
+                ActionKind.LAUNCH_ACTION -> launchAction(ctx, spec.payload)
+                ActionKind.BROADCAST -> broadcast(ctx, spec.payload)
+                ActionKind.SWIPE -> swipe(spec.payload)
+                ActionKind.SCROLL -> scroll(spec.payload)
+            }
+        } catch (e: Exception) {
+            Log.w(TAG, "action " + spec.kind + "/" + spec.payload + " failed", e)
+            false
+        }
+    }
+
+    private fun global(name: String): Boolean {
+        val svc = ServiceHolder.service ?: return false
+        val action = GlobalAction.fromName(name) ?: return false
+        if (!action.available) return false
+        return svc.performGlobalAction(action.id)
+    }
+
+    private fun volume(ctx: Context, dir: String): Boolean {
+        val am = ctx.getSystemService(AudioManager::class.java) ?: return false
+        val adjust = when (dir) {
+            "raise" -> AudioManager.ADJUST_RAISE
+            "lower" -> AudioManager.ADJUST_LOWER
+            "mute" -> AudioManager.ADJUST_TOGGLE_MUTE
+            "panel" -> AudioManager.ADJUST_SAME
+            else -> return false
+        }
+        am.adjustSuggestedStreamVolume(
+            adjust,
+            AudioManager.USE_DEFAULT_STREAM_TYPE,
+            AudioManager.FLAG_SHOW_UI
+        )
+        return true
+    }
+
+    private fun media(ctx: Context, what: String): Boolean {
+        val code = when (what) {
+            "play_pause" -> KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
+            "next" -> KeyEvent.KEYCODE_MEDIA_NEXT
+            "previous" -> KeyEvent.KEYCODE_MEDIA_PREVIOUS
+            "stop" -> KeyEvent.KEYCODE_MEDIA_STOP
+            else -> return false
+        }
+        val am = ctx.getSystemService(AudioManager::class.java) ?: return false
+        val now = System.currentTimeMillis()
+        am.dispatchMediaKeyEvent(KeyEvent(now, now, KeyEvent.ACTION_DOWN, code, 0))
+        am.dispatchMediaKeyEvent(KeyEvent(now, now, KeyEvent.ACTION_UP, code, 0))
+        return true
+    }
+
+    private fun launchApp(ctx: Context, pkg: String): Boolean {
+        val i = ctx.packageManager.getLaunchIntentForPackage(pkg) ?: return false
+        i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+        ctx.startActivity(i)
+        return true
+    }
+
+    /**
+     * An explicit component needs no intent-filter on the target, which is how
+     * the internal Viwoods AI activities get reached.
+     */
+    private fun launchComponent(ctx: Context, flat: String): Boolean {
+        val cn = ComponentName.unflattenFromString(flat) ?: return false
+        val i = Intent()
+        i.component = cn
+        i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+        ctx.startActivity(i)
+        return true
+    }
+
+    private fun launchAction(ctx: Context, action: String): Boolean {
+        val i = Intent(action).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+        if (i.resolveActivity(ctx.packageManager) == null) return false
+        ctx.startActivity(i)
+        return true
+    }
+
+    private fun broadcast(ctx: Context, action: String): Boolean {
+        ctx.sendBroadcast(Intent(action))
+        return true
+    }
+
+    /**
+     * Synthetic swipe - the page-turn action for reader apps that only respond
+     * to touch. Uses display bounds so it scales to any screen.
+     */
+    private fun swipe(dir: String): Boolean {
+        val svc = ServiceHolder.service ?: return false
+        val dm = svc.resources.displayMetrics
+        val w = dm.widthPixels.toFloat()
+        val h = dm.heightPixels.toFloat()
+        val cx = w / 2f
+        val cy = h / 2f
+        val dx = w * 0.35f
+        val dy = h * 0.35f
+
+        var sx = cx; var sy = cy; var ex = cx; var ey = cy
+        when (dir) {
+            "left" -> { sx = cx + dx; ex = cx - dx }
+            "right" -> { sx = cx - dx; ex = cx + dx }
+            "up" -> { sy = cy + dy; ey = cy - dy }
+            "down" -> { sy = cy - dy; ey = cy + dy }
+            else -> return false
+        }
+
+        val path = Path()
+        path.moveTo(sx, sy)
+        path.lineTo(ex, ey)
+        val stroke = GestureDescription.StrokeDescription(path, 0L, 120L)
+        return svc.dispatchGesture(
+            GestureDescription.Builder().addStroke(stroke).build(), null, null
+        )
+    }
+
+    private fun scroll(dir: String): Boolean {
+        val svc = ServiceHolder.service ?: return false
+        val action = if (dir == "backward") {
+            AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD
+        } else {
+            AccessibilityNodeInfo.ACTION_SCROLL_FORWARD
+        }
+        val root = svc.rootInActiveWindow ?: return false
+        val target = findScrollable(root) ?: return false
+        return target.performAction(action)
+    }
+
+    private fun findScrollable(node: AccessibilityNodeInfo?): AccessibilityNodeInfo? {
+        if (node == null) return null
+        if (node.isScrollable) return node
+        for (i in 0 until node.childCount) {
+            val hit = findScrollable(node.getChild(i))
+            if (hit != null) return hit
+        }
+        return null
+    }
+
+    /** True when this action cannot run without the accessibility service. */
+    fun requiresAccessibility(spec: ActionSpec): Boolean = when (spec.kind) {
+        ActionKind.GLOBAL, ActionKind.SWIPE, ActionKind.SCROLL -> true
+        else -> false
+    }
+}
diff --git a/app/src/main/java/dev/equwal/assistkey/route/ServiceHolder.kt b/app/src/main/java/dev/equwal/assistkey/route/ServiceHolder.kt
new file mode 100644
index 0000000..34eca74
--- /dev/null
+++ b/app/src/main/java/dev/equwal/assistkey/route/ServiceHolder.kt
@@ -0,0 +1,15 @@
+package dev.equwal.assistkey.route
+
+import android.accessibilityservice.AccessibilityService
+
+/**
+ * Lets any entry point (assist activity, wallet service, camera shim) reach the
+ * running AccessibilityService without a bind round-trip, which would add tens
+ * of milliseconds to something the user perceives as a button press.
+ */
+object ServiceHolder {
+    @Volatile
+    var service: AccessibilityService? = null
+
+    val isRunning: Boolean get() = service != null
+}
diff --git a/app/src/main/java/dev/equwal/assistkey/store/Bindings.kt b/app/src/main/java/dev/equwal/assistkey/store/Bindings.kt
new file mode 100644
index 0000000..c5c2e79
--- /dev/null
+++ b/app/src/main/java/dev/equwal/assistkey/store/Bindings.kt
@@ -0,0 +1,56 @@
+package dev.equwal.assistkey.store
+
+import dev.equwal.assistkey.model.ActionKind
+import dev.equwal.assistkey.model.ActionSpec
+import dev.equwal.assistkey.model.GestureType
+import dev.equwal.assistkey.model.HwKey
+import dev.equwal.assistkey.model.Trigger
+
+/**
+ * An immutable snapshot of every binding, with the lookups the key-event hot
+ * path needs precomputed. Rebuilt on save rather than queried per keystroke.
+ */
+class Bindings(private val map: Map<Trigger, ActionSpec>) {
+
+    private val active: Map<Trigger, ActionSpec> =
+        map.filterValues { it.kind != ActionKind.PASS_THROUGH }
+
+    private val tapsByKeys: Map<Set<HwKey>, Int> =
+        active.keys.filter { it.type == GestureType.TAP }
+            .groupBy { it.keys }
+            .mapValues { (_, v) -> v.maxOf { it.count } }
+
+    private val holdKeys: Set<Set<HwKey>> =
+        active.keys.filter { it.type == GestureType.HOLD }.map { it.keys }.toSet()
+
+    /** For each key, the other keys it forms a bound chord with. */
+    private val partners: Map<HwKey, Set<HwKey>> = buildMap {
+        active.keys.filter { it.isChord }.forEach { t ->
+            t.keys.forEach { k -> merge(k, t.keys - k) { a, b -> a + b } }
+        }
+    }
+
+    fun maxTaps(keys: Set<HwKey>): Int = tapsByKeys[keys] ?: 0
+    fun hasHold(keys: Set<HwKey>): Boolean = keys in holdKeys
+    fun chordPartners(key: HwKey): Set<HwKey> = partners[key] ?: emptySet()
+
+    operator fun get(trigger: Trigger): ActionSpec? = active[trigger]
+    fun isBound(trigger: Trigger): Boolean = trigger in active
+
+    /** Including PASS_THROUGH entries, for the UI. */
+    fun raw(trigger: Trigger): ActionSpec = map[trigger] ?: ActionSpec.PASS
+    fun all(): Map<Trigger, ActionSpec> = map
+
+    /** Key sets the engine should watch at all. */
+    fun watchedKeys(): Set<HwKey> =
+        (tapsByKeys.keys + holdKeys).flatten().toSet()
+
+    fun with(trigger: Trigger, spec: ActionSpec): Bindings =
+        Bindings(map.toMutableMap().apply {
+            if (spec.kind == ActionKind.PASS_THROUGH) remove(trigger) else put(trigger, spec)
+        })
+
+    companion object {
+        val EMPTY = Bindings(emptyMap())
+    }
+}
diff --git a/app/src/main/java/dev/equwal/assistkey/store/Store.kt b/app/src/main/java/dev/equwal/assistkey/store/Store.kt
new file mode 100644
index 0000000..b0e53e3
--- /dev/null
+++ b/app/src/main/java/dev/equwal/assistkey/store/Store.kt
@@ -0,0 +1,74 @@
+package dev.equwal.assistkey.store
+
+import android.content.Context
+import android.content.SharedPreferences
+import dev.equwal.assistkey.engine.GestureEngine
+import dev.equwal.assistkey.model.ActionSpec
+import dev.equwal.assistkey.model.Trigger
+import org.json.JSONObject
+
+/**
+ * Persistence. Bindings live as one JSON blob so adding an action kind never
+ * needs a migration, and the parsed result is cached because the key-event
+ * path must not touch disk.
+ */
+object Store {
+
+    private const val PREFS = "assistkey"
+    private const val K_BINDINGS = "bindings"
+    private const val K_MULTITAP = "multitap_ms"
+    private const val K_HOLD = "hold_ms"
+    private const val K_CHORD = "chord_ms"
+
+    @Volatile private var cache: Bindings? = null
+
+    private fun prefs(c: Context): SharedPreferences =
+        c.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+
+    fun bindings(c: Context): Bindings = cache ?: load(c).also { cache = it }
+
+    private fun load(c: Context): Bindings {
+        val raw = prefs(c).getString(K_BINDINGS, null) ?: return Bindings.EMPTY
+        return runCatching {
+            val o = JSONObject(raw)
+            val m = HashMap<Trigger, ActionSpec>()
+            o.keys().forEach { id ->
+                val t = Trigger.parse(id) ?: return@forEach
+                m[t] = ActionSpec.fromJson(o.getJSONObject(id))
+            }
+            Bindings(m)
+        }.getOrDefault(Bindings.EMPTY)
+    }
+
+    fun save(c: Context, b: Bindings) {
+        val o = JSONObject()
+        b.all().forEach { (t, spec) -> o.put(t.id, spec.toJson()) }
+        prefs(c).edit().putString(K_BINDINGS, o.toString()).apply()
+        cache = b
+    }
+
+    fun bind(c: Context, trigger: Trigger, spec: ActionSpec) {
+        save(c, bindings(c).with(trigger, spec))
+    }
+
+    fun timing(c: Context): GestureEngine.Config {
+        val p = prefs(c)
+        val d = GestureEngine.Config()
+        return GestureEngine.Config(
+            multiTapMs = p.getLong(K_MULTITAP, d.multiTapMs),
+            holdMs = p.getLong(K_HOLD, d.holdMs),
+            chordMs = p.getLong(K_CHORD, d.chordMs)
+        )
+    }
+
+    fun setTiming(c: Context, cfg: GestureEngine.Config) {
+        prefs(c).edit()
+            .putLong(K_MULTITAP, cfg.multiTapMs)
+            .putLong(K_HOLD, cfg.holdMs)
+            .putLong(K_CHORD, cfg.chordMs)
+            .apply()
+    }
+
+    /** Forces the next read to hit disk. */
+    fun invalidate() { cache = null }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..f4aa707
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="app_name">AssistKey</string>
+    <string name="service_label">AssistKey button actions</string>
+    <string name="service_description">Performs Back, Home, Recents and other navigation actions when triggered by the power button. No data is collected or transmitted.</string>
+
+    <string name="heading">Long-press power → action</string>
+    <string name="blurb">Pick what a long press of the power button should do. The power menu stays available on Power + Volume Up.</string>
+
+    <string name="action_back">Back</string>
+    <string name="action_home">Home</string>
+    <string name="action_recents">Recent apps</string>
+    <string name="action_notifications">Notification shade</string>
+    <string name="action_quick_settings">Quick settings</string>
+    <string name="action_screenshot">Screenshot</string>
+    <string name="action_lock">Lock screen</string>
+
+    <string name="setup_heading">Setup — both steps are required</string>
+    <string name="btn_accessibility">1. Enable the accessibility service</string>
+    <string name="btn_assistant">2. Set AssistKey as the digital assistant</string>
+    <string name="btn_test">Test the selected action</string>
+
+    <string name="status_ok">Ready</string>
+    <string name="status_no_service">Accessibility service is off — step 1 not done</string>
+    <string name="status_not_assistant">Not the default assistant — step 2 not done</string>
+    <string name="assistant_hint">Choose “Default digital assistant app”, then pick AssistKey.</string>
+    <string name="toast_no_service">Enable the accessibility service first.</string>
+</resources>
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..6ec913d
--- /dev/null
+++ b/app/src/main/res/values/styles.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <!--
+        The assist activity must never be seen. Translucent, no title, no window
+        animation - otherwise you get a visible flash every time you long-press power.
+    -->
+    <style name="Theme.Invisible" parent="@android:style/Theme.Translucent.NoTitleBar">
+        <item name="android:windowBackground">@android:color/transparent</item>
+        <item name="android:windowIsTranslucent">true</item>
+        <item name="android:windowAnimationStyle">@null</item>
+        <item name="android:windowNoTitle">true</item>
+        <item name="android:backgroundDimEnabled">false</item>
+    </style>
+</resources>
diff --git a/app/src/main/res/xml/accessibility_service_config.xml b/app/src/main/res/xml/accessibility_service_config.xml
new file mode 100644
index 0000000..842cf07
--- /dev/null
+++ b/app/src/main/res/xml/accessibility_service_config.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    Deliberately minimal. We never inspect window content, so we do not request
+    canRetrieveWindowContent. The service exists purely to call performGlobalAction.
+-->
+<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
+    android:accessibilityEventTypes="typeWindowStateChanged"
+    android:accessibilityFeedbackType="feedbackGeneric"
+    android:accessibilityFlags="flagDefault"
+    android:canPerformGestures="false"
+    android:canRetrieveWindowContent="false"
+    android:description="@string/service_description"
+    android:notificationTimeout="100" />
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..5917b7d
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,5 @@
+// Versions are pinned here and verified against the installed SDK at build time.
+plugins {
+    id("com.android.application") version "8.9.0" apply false
+    id("org.jetbrains.kotlin.android") version "2.1.0" apply false
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..9d9fecd
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,6 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+org.gradle.parallel=true
+org.gradle.caching=true
+android.useAndroidX=false
+android.nonTransitiveRClass=true
+kotlin.code.style=official
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..aaa8535
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,24 @@
+pluginManagement {
+    repositories {
+        google {
+            content {
+                includeGroupByRegex("com\\.android.*")
+                includeGroupByRegex("com\\.google.*")
+                includeGroupByRegex("androidx.*")
+            }
+        }
+        mavenCentral()
+        gradlePluginPortal()
+    }
+}
+
+dependencyResolutionManagement {
+    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+    repositories {
+        google()
+        mavenCentral()
+    }
+}
+
+rootProject.name = "AssistKey"
+include(":app")