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 4a87b58b1392385687a9cc5ad4b766d5e9aa2fec
truex <truex@equwal.com>
2026-09-20 21:04:30 -0700

Add Navigation, Power key combinations and the AI key hook (0.0.2-alpha)

Navigation screen: button bar, swipe gestures and Power key combinations in any
mix, with five named setups. The system half cannot be switched by an app, so
the screen reports live state and gives the commands; tools/nav-mode runs them.
Measured: the gestural overlay removes the bar outright on this firmware, the
swipe-up Home gesture is Viwoods' own (Settings.System disable_gesture_bottom),
and edge-swipe Back only goes away with the inset scale at zero. The overlay
has to be switched before the setting is written or SystemUI forgets it.

Power key combinations: a held Power announces itself through the assistant
role, and a key pressed while it is still down reaches the filter, so "hold
Power, then AI / Volume up / Volume down" are three real combinations. Back,
Home and Recents on them keep working when the app is locked, so a reader with
no bar and no gestures cannot be bricked by an expired trial.

AI key: the firmware opens its AI screen on every press whether or not an app
consumed the key; earlier tests only looked right because Home landed last.
With CustomAiKey set to anything else the firmware launches that component and
the filter never sees the key. AiKeyActivity is that component: taps are
counted from launches. tools/ai-key hook|unhook|status.

Bindings no longer tell the gesture engine about triggers involving Power.

 CHANGELOG.md                                       |  24 ++
 README.md                                          |  53 ++++-
 app/src/main/AndroidManifest.xml                   |  16 ++
 .../dev/equwal/assistkey/channel/AiKeyActivity.kt  | 101 ++++++++
 .../dev/equwal/assistkey/channel/ChannelEntry.kt   |  17 +-
 .../equwal/assistkey/engine/KeyFilterService.kt    |  74 ++++++
 .../java/dev/equwal/assistkey/model/Trigger.kt     |   8 +
 .../java/dev/equwal/assistkey/native/NavNative.kt  |  63 +++++
 .../dev/equwal/assistkey/native/ViwoodsBridge.kt   |  23 +-
 .../java/dev/equwal/assistkey/store/Bindings.kt    |  21 +-
 .../java/dev/equwal/assistkey/ui/ChordActivity.kt  |   2 +-
 .../java/dev/equwal/assistkey/ui/MainActivity.kt   |   5 +
 .../dev/equwal/assistkey/ui/NavigationActivity.kt  | 257 +++++++++++++++++++++
 .../java/dev/equwal/assistkey/ui/PowerActivity.kt  |  23 ++
 .../dev/equwal/assistkey/ui/TriggerListActivity.kt |  13 ++
 app/src/main/java/dev/equwal/assistkey/ui/Ui.kt    |   4 +
 .../dev/equwal/assistkey/ui/ViwoodsActivity.kt     |  26 ++-
 gradle.properties                                  |   4 +-
 play/LISTING.md                                    |   2 +
 tools/ai-key                                       |  21 ++
 tools/nav-mode                                     |  46 ++++
 tools/nav-mode.ps1                                 |  27 +++
 22 files changed, 814 insertions(+), 16 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 83fc413..6c32b78 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,29 @@
 # Changelog
 
+## 0.0.2-alpha — 2026-09-20
+
+Version code 2.
+
+- **Navigation screen.** Button bar, swipe gestures and Power key combinations
+  in any mix, with five named setups. The system half cannot be switched by an
+  app, so the screen reports the live state and gives the commands;
+  `tools/nav-mode` runs them.
+- **Power key combinations.** Hold Power, then press the AI key, Volume up or
+  Volume down. Defaults to Home, Back and Recents. These three keep working
+  when the app is locked, so a reader with no bar and no gestures always has a
+  way out.
+- **AI key hook.** Found that the firmware opens its AI screen on every AI key
+  press whether or not an app consumed it. `tools/ai-key hook` points the
+  firmware at a new entry point instead; taps then work cleanly. The app says
+  which state it is in and what that costs.
+- Fixed: "ai key" in labels.
+
+Known limits:
+
+- The notification-shade action does nothing on this firmware.
+- With the AI key hooked, that key has taps only: no hold, no volume
+  combinations.
+
 ## 0.0.1-alpha — 2026-09-20
 
 First alpha. Version code 1.
diff --git a/README.md b/README.md
index c6b25fc..e83d95a 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,9 @@ enters the pipeline exactly where the hardware does.
 
 | Press | Node | Result |
 |---|---|---|
-| AI key (`KEY_F1`) | `event5` "AI KEY" | Reaches the accessibility filter, whatever `CustomAiKey` holds |
+| AI key (`KEY_F1`), stock or unset hook | `event5` "AI KEY" | Reaches the filter, **and the firmware opens its AI screen anyway**, consumed or not |
+| AI key, hook set to anything else | `event5` | Firmware launches that component; the filter never sees the key |
+| Power held, then another key | `event1` + key | The second key reaches the filter; no screenshot or power-menu chord fires |
 | Volume up / down | `event1` / `event2` | Reaches the filter **only while its firmware hook is unset** |
 | Power, held 700 ms | `event1` | Firmware fires `ACTION_ASSIST` at the assistant role holder — us |
 | Power, twice within 300 ms | `event1` | `GestureLauncherService` fires `STILL_IMAGE_CAMERA`; never the wallet |
@@ -48,6 +50,16 @@ holds **any** value — even its default token, `volume_up` — the firmware dea
 with that key before the filter stage and no app ever sees it. Unset, the key
 arrives normally. The device's own key-settings screen sets them.
 
+The AI key is worse. With the stock hook the filter does see it, but the
+firmware opens its AI screen on every press regardless, so a binding fires on
+top of that screen. The only clean route is to point the hook at this app's
+`AiKeyActivity`: every press then arrives as a launch, which is enough to count
+taps (but not to see a release, so no hold and no volume combinations):
+
+```bash
+tools/ai-key hook      # or: unhook, status
+```
+
 An ordinary app cannot write these. `SettingsProvider` rejects any
 `Settings.System` name outside its public list unless the caller is a
 privileged system app, with or without `WRITE_SECURE_SETTINGS`:
@@ -74,7 +86,12 @@ Android version, can see it. That leaves exactly three reachable slots:
   *Always*.
 - **Press and hold** — arrives as an assistant request.
 
-No multi-tap beyond two, and Power can never be half of a combination.
+No multi-tap beyond two. Power cannot take part in an ordinary combination,
+but there is one way in: a held Power announces itself, because the firmware
+fires the assistant at us, and a key pressed while it is still down reaches the
+filter. So **hold Power, then press** the AI key, Volume up or Volume down is
+three real combinations. While any is bound, the plain hold action waits one
+second to see whether a key follows.
 `Power + Volume up` is reserved by the firmware for the power menu, which the
 app deliberately leaves alone as an escape hatch.
 
@@ -91,6 +108,37 @@ highest bound tap count is 1 fires on key-up without waiting out the multi-tap
 window.* You only pay multi-tap latency on keys where you actually asked for a
 double tap.
 
+## Navigation
+
+*Navigation* on the main screen mixes three ways of getting around: the
+three-button bar, swipe gestures, and Power key combinations (defaults: Power
+then Volume up = Back, the AI key = Home, Volume down = Recents). Five named
+setups, or tick any mix.
+
+The combinations are the app's own. The bar and the gestures are the system's,
+and Android lets no app switch them, so the screen shows the current state and
+the commands, and `tools/nav-mode` runs them:
+
+```bash
+tools/nav-mode buttons   nogestures    # bar only, no gestures at all
+tools/nav-mode nobuttons gestures      # gestures only
+tools/nav-mode nobuttons nogestures    # neither: Power combinations only
+tools/nav-mode status
+```
+
+| Piece | Switch |
+|---|---|
+| Button bar | overlay `com.android.internal.systemui.navbar.threebutton` / `.gestural` (gestural removes the bar outright on this firmware) |
+| Swipe up for Home (Viwoods' own, both modes) | `Settings.System disable_gesture_bottom` |
+| Edge swipe for Back (gestural mode only) | `Settings.Secure back_gesture_inset_scale_left/right` = 0 |
+
+Order matters: changing the overlay makes SystemUI forget
+`disable_gesture_bottom`, so the overlay goes first.
+
+Back, Home and Recents on a Power combination keep working when the app is
+locked. A reader with no bar and no gestures is navigated entirely by them, and
+an expired trial must not turn it into a brick.
+
 ## Actions
 
 Parity with the stock Viwoods key screen, plus everything it does not offer:
@@ -233,6 +281,7 @@ route/     turning an action spec into behaviour
 store/     persistence, with the key-event hot path precomputed
 ui/        the configuration screens, built in code
 
+tools/     adb helpers: nav-mode (bar and gestures), ai-key (the AI key hook)
 play/      everything for the Play Console: checklist, listing, policy, graphics
 src/debug/ a debug-only hook that renders each screen to a PNG, because the
            e-ink panel defeats `adb shell screencap`
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c189e06..90b0b92 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -57,6 +57,7 @@
         <activity android:name=".ui.TimingActivity" android:exported="false" />
         <activity android:name=".ui.KeyTesterActivity" android:exported="false" />
         <activity android:name=".ui.LicenseActivity" android:exported="false" />
+        <activity android:name=".ui.NavigationActivity" android:exported="false" />
 
         <!--
             The three impersonation entry points. Each starts disabled: the app
@@ -88,6 +89,21 @@
             </intent-filter>
         </activity>
 
+        <!--
+            Target for the firmware's own AI key hook (Settings.System
+            CustomAiKey), which only the shell can set. No intent filter: it is
+            reached by explicit component, and only by the firmware. Always
+            enabled, because the hook outlives any switch inside the app.
+        -->
+        <activity
+            android:name=".channel.AiKeyActivity"
+            android:exported="true"
+            android:excludeFromRecents="true"
+            android:noHistory="true"
+            android:launchMode="singleInstance"
+            android:taskAffinity=""
+            android:theme="@style/Theme.Invisible" />
+
         <!-- Double press Power, when the firmware target is the camera. -->
         <activity
             android:name=".channel.CameraShimActivity"
diff --git a/app/src/main/java/dev/equwal/assistkey/channel/AiKeyActivity.kt b/app/src/main/java/dev/equwal/assistkey/channel/AiKeyActivity.kt
new file mode 100644
index 0000000..512d501
--- /dev/null
+++ b/app/src/main/java/dev/equwal/assistkey/channel/AiKeyActivity.kt
@@ -0,0 +1,101 @@
+package dev.equwal.assistkey.channel
+
+import android.app.Activity
+import android.content.Context
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import dev.equwal.assistkey.engine.KeyFilterService
+import dev.equwal.assistkey.license.License
+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.Presets
+import dev.equwal.assistkey.model.Trigger
+import dev.equwal.assistkey.route.ActionRouter
+import dev.equwal.assistkey.route.ServiceHolder
+import dev.equwal.assistkey.store.Store
+
+/**
+ * Where the AI key lands once the firmware hook points here.
+ *
+ * Measured on firmware 1.5.6: with the stock hook the firmware opens its AI
+ * screen on every press whether or not an app consumed the key, so a binding
+ * made through the key filter always fires on top of that screen. With the
+ * hook set to anything else the firmware launches that component instead and
+ * the filter never sees the key. Pointing it here is therefore the only way to
+ * own the AI key cleanly:
+ *
+ *   adb shell settings put system CustomAiKey \
+ *       dev.equwal.assistkey/dev.equwal.assistkey.channel.AiKeyActivity
+ *
+ * Each press arrives as one launch, which is enough to count taps but says
+ * nothing about release - so taps, and "hold Power, then the AI key", but no
+ * press-and-hold and no combinations with the volume keys.
+ */
+class AiKeyActivity : Activity() {
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        val svc = ServiceHolder.service as? KeyFilterService
+
+        when {
+            // 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.
+            !License.active(this) -> ActionRouter.run(this, STOCK)
+            else -> AiKeyTaps.press(this)
+        }
+
+        finish()
+        @Suppress("DEPRECATION")
+        overridePendingTransition(0, 0)
+    }
+
+    companion object {
+        val STOCK = ActionSpec(ActionKind.LAUNCH_COMPONENT, Presets.STOCK_AI_KEY)
+    }
+}
+
+/** Counts launches into taps, with the same rules the gesture engine uses. */
+object AiKeyTaps {
+
+    private val keys = setOf(HwKey.AI)
+    private val handler = Handler(Looper.getMainLooper())
+    private var count = 0
+    private var pending: Runnable? = null
+
+    fun press(activity: Activity) {
+        val app = activity.applicationContext
+        val max = Store.bindings(app).maxTaps(keys)
+        if (max == 0) {
+            // Nothing bound: be the stock key, and do it while still in front,
+            // because a background launch would be refused.
+            ActionRouter.run(activity, AiKeyActivity.STOCK)
+            return
+        }
+        pending?.let { handler.removeCallbacks(it) }
+        count++
+        val n = count
+        // The highest bound count cannot grow into anything, so it fires now;
+        // anything lower has to wait out the multi-tap window.
+        val delay = if (n >= max) SETTLE_MS else Store.timing(app).multiTapMs.toLong()
+        val r = Runnable { fire(app, n) }
+        pending = r
+        handler.postDelayed(r, delay)
+    }
+
+    private fun fire(app: Context, n: Int) {
+        count = 0
+        pending = null
+        val spec = Store.bindings(app)[Trigger(keys, GestureType.TAP, n)]
+            ?: if (n == 1) AiKeyActivity.STOCK else return
+        // The accessibility service may start activities from the background;
+        // a plain application context may not.
+        ActionRouter.run(ServiceHolder.service ?: app, spec)
+    }
+
+    /** Long enough for our own invisible window to be gone before Back or Home lands. */
+    private const val SETTLE_MS = 150L
+}
diff --git a/app/src/main/java/dev/equwal/assistkey/channel/ChannelEntry.kt b/app/src/main/java/dev/equwal/assistkey/channel/ChannelEntry.kt
index 1a66b65..4e845d7 100644
--- a/app/src/main/java/dev/equwal/assistkey/channel/ChannelEntry.kt
+++ b/app/src/main/java/dev/equwal/assistkey/channel/ChannelEntry.kt
@@ -5,6 +5,7 @@ import android.os.Handler
 import android.os.Looper
 import android.util.Log
 import android.widget.Toast
+import dev.equwal.assistkey.engine.KeyFilterService
 import dev.equwal.assistkey.license.License
 import dev.equwal.assistkey.route.ActionRouter
 import dev.equwal.assistkey.route.ServiceHolder
@@ -25,7 +26,21 @@ object ChannelEntry {
      * activity launch restrictions would drop it.
      */
     fun handle(activity: Activity, channel: Channel) {
-        if (!License.active(activity)) {
+        val licensed = License.active(activity)
+
+        // A Power hold with combinations bound does not act yet: it opens a
+        // short window in which the next key press decides what happens.
+        if (channel == Channel.ASSISTANT && Store.bindings(activity).hasPowerCombos) {
+            val svc = ServiceHolder.service as? KeyFilterService
+            if (svc != null) {
+                val plain = channel.trigger?.let { Store.bindings(activity)[it] }
+                svc.armPowerCombo(plain, lifeline = !licensed)
+                activity.finishAndVanish()
+                return
+            }
+        }
+
+        if (!licensed) {
             Toast.makeText(
                 activity,
                 "AssistKey is locked - open the app to unlock it",
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 ea7316e..2f55726 100644
--- a/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt
+++ b/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt
@@ -4,6 +4,7 @@ import android.accessibilityservice.AccessibilityService
 import android.media.AudioManager
 import android.os.Handler
 import android.os.Looper
+import android.os.SystemClock
 import android.view.KeyEvent
 import android.view.accessibility.AccessibilityEvent
 import dev.equwal.assistkey.channel.Channel
@@ -12,6 +13,7 @@ import dev.equwal.assistkey.license.License
 import dev.equwal.assistkey.license.PlayBilling
 import dev.equwal.assistkey.model.ActionKind
 import dev.equwal.assistkey.model.ActionSpec
+import dev.equwal.assistkey.model.GlobalAction
 import dev.equwal.assistkey.model.HwKey
 import dev.equwal.assistkey.model.Trigger
 import dev.equwal.assistkey.native.ViwoodsBridge
@@ -53,10 +55,73 @@ class KeyFilterService : AccessibilityService(), GestureEngine.Host {
         super.onDestroy()
     }
 
+    // ---- hold Power, then press a key --------------------------------------
+
+    private var armedUntil = 0L
+    private var lifelineOnly = false
+    private var holdFallback: Runnable? = null
+    private val swallowed = HashSet<HwKey>()
+
+    /**
+     * Called when the firmware reports a Power hold. For [POWER_COMBO_WINDOW_MS]
+     * the next key press is read as a Power combination. If none comes, the
+     * plain hold action runs - late by exactly that window, which is the price
+     * of having combinations at all and is only paid when one is bound.
+     *
+     * [lifeline] is the locked state. A reader set up with no button bar and no
+     * gestures is navigated entirely from here, and must not become a brick
+     * because a trial ran out: Back, Home and Recents always work.
+     */
+    fun armPowerCombo(plainHold: ActionSpec?, lifeline: Boolean) {
+        holdFallback?.let { handler.removeCallbacks(it) }
+        armedUntil = SystemClock.uptimeMillis() + POWER_COMBO_WINDOW_MS
+        lifelineOnly = lifeline
+        val r = Runnable {
+            armedUntil = 0L
+            holdFallback = null
+            if (!lifeline) plainHold?.let { ActionRouter.run(this, it) }
+        }
+        holdFallback = r
+        handler.postDelayed(r, POWER_COMBO_WINDOW_MS)
+    }
+
+    private fun powerCombo(event: KeyEvent, key: HwKey): Boolean {
+        if (event.action == KeyEvent.ACTION_UP) return swallowed.remove(key)
+        if (event.action != KeyEvent.ACTION_DOWN) return false
+        if (key in swallowed) return true // auto-repeat of a press already taken
+        if (!takePowerCombo(key)) return false
+        swallowed.add(key)
+        return true
+    }
+
+    /**
+     * If a Power hold is waiting for its second key and [key] is bound as one,
+     * run it and report true. Public because the AI key, once its firmware hook
+     * points at this app, arrives as an activity launch rather than a key event.
+     */
+    fun takePowerCombo(key: HwKey): Boolean {
+        if (SystemClock.uptimeMillis() >= armedUntil) return false
+        val spec = bindings().powerCombo(key) ?: return false
+        if (lifelineOnly && !isLifeline(spec)) return false
+
+        armedUntil = 0L
+        holdFallback?.let { handler.removeCallbacks(it) }
+        holdFallback = null
+        // A beat, so that whichever invisible window of ours is in front has
+        // gone before Back or Home lands on it.
+        handler.postDelayed({ ActionRouter.run(this, spec) }, 150L)
+        return true
+    }
+
+    private fun isLifeline(spec: ActionSpec): Boolean =
+        spec.kind == ActionKind.GLOBAL && spec.payload in LIFELINE
+
     override fun onKeyEvent(event: KeyEvent): Boolean {
         val key = HwKey.fromCode(event.keyCode)
         val consumed = when {
             !Channels.isEnabled(this, Channel.ACCESSIBILITY) -> false
+            // 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.
             !License.active(this) -> false
             key == null || !key.interceptable -> false
@@ -99,6 +164,15 @@ class KeyFilterService : AccessibilityService(), GestureEngine.Host {
 
     override fun chordPartners(key: HwKey): Set<HwKey> = bindings().chordPartners(key)
 
+    companion object {
+        /** How long after a Power hold a key press still counts as a combination. */
+        const val POWER_COMBO_WINDOW_MS = 1000L
+
+        private val LIFELINE = setOf(
+            GlobalAction.BACK.name, GlobalAction.HOME.name, GlobalAction.RECENTS.name
+        )
+    }
+
     override fun fire(trigger: Trigger) {
         val spec = bindings()[trigger] ?: return
         ActionRouter.run(this, spec)
diff --git a/app/src/main/java/dev/equwal/assistkey/model/Trigger.kt b/app/src/main/java/dev/equwal/assistkey/model/Trigger.kt
index 1c1a6e9..616b24d 100644
--- a/app/src/main/java/dev/equwal/assistkey/model/Trigger.kt
+++ b/app/src/main/java/dev/equwal/assistkey/model/Trigger.kt
@@ -43,6 +43,14 @@ data class Trigger(
     companion object {
         const val MAX_TAPS = 5
 
+        /**
+         * "Hold Power, then press [key]". Power cannot take part in an ordinary
+         * combination, but a held Power announces itself - the firmware fires
+         * the assistant at us - and a key pressed while it is still down
+         * reaches the filter. See KeyFilterService.armPowerCombo.
+         */
+        fun powerThen(key: HwKey): Trigger = Trigger(setOf(HwKey.POWER, key), GestureType.TAP, 1)
+
         fun parse(id: String): Trigger? {
             val parts = id.split(":")
             if (parts.size != 3) return null
diff --git a/app/src/main/java/dev/equwal/assistkey/native/NavNative.kt b/app/src/main/java/dev/equwal/assistkey/native/NavNative.kt
new file mode 100644
index 0000000..a44fd6a
--- /dev/null
+++ b/app/src/main/java/dev/equwal/assistkey/native/NavNative.kt
@@ -0,0 +1,63 @@
+package dev.equwal.assistkey.native
+
+import android.app.Activity
+import android.content.Context
+import android.provider.Settings
+import android.view.WindowInsets
+
+/**
+ * How the reader is navigated, as far as the system is concerned: whether the
+ * three-button bar is showing, and whether swipe gestures are live.
+ *
+ * Both can be read here and neither can be changed here. Measured on firmware
+ * 1.5.6:
+ *
+ *  - The bar is Android's navigation-mode overlay. `threebutton` shows it;
+ *    `gestural` removes it outright (no pill, no inset) and brings Android's
+ *    edge-swipe Back with it. Switching overlays is a shell-only operation.
+ *  - The swipe up from the bottom edge to Home is Viwoods' own, present in both
+ *    modes, and is switched by Settings.System `disable_gesture_bottom`. That
+ *    is an OEM key outside SettingsProvider's public list, so no ordinary app
+ *    may write it, whatever permissions it holds.
+ *  - In gestural mode the edge-swipe Back can only be removed by shrinking its
+ *    inset to nothing: Settings.Secure back_gesture_inset_scale_left/right = 0.
+ *
+ * Order matters when applying. Changing the overlay makes SystemUI forget
+ * `disable_gesture_bottom`, so the overlay goes first and the setting after.
+ */
+object NavNative {
+
+    private const val GESTURE_BOTTOM = "disable_gesture_bottom"
+    private const val OVERLAY = "com.android.internal.systemui.navbar."
+
+    /** True if the three-button bar is on screen, null if it cannot be told yet. */
+    fun buttonsShowing(a: Activity): Boolean? {
+        val insets = a.window.decorView.rootWindowInsets ?: return null
+        val bar = insets.getInsetsIgnoringVisibility(WindowInsets.Type.navigationBars())
+        val dp = a.resources.displayMetrics.density
+        // The button bar is about 48dp tall. Gestural mode leaves nothing here
+        // on this firmware, and a thin pill strip on stock Android.
+        return maxOf(bar.bottom, bar.left, bar.right) >= 40 * dp
+    }
+
+    /** True if the swipe-up gesture is live, null if the setting cannot be read. */
+    fun gesturesOn(c: Context): Boolean? {
+        val v = runCatching { Settings.System.getString(c.contentResolver, GESTURE_BOTTOM) }
+            .getOrElse { return null }
+        return v?.trim() != "1"
+    }
+
+    /** The commands that produce the wanted state, in the order they must run. */
+    fun commands(buttons: Boolean, gestures: Boolean): List<String> {
+        val out = ArrayList<String>()
+        out += "adb shell cmd overlay enable-exclusive --category " + OVERLAY +
+            (if (buttons) "threebutton" else "gestural")
+        out += "adb shell settings put system " + GESTURE_BOTTOM + " " + (if (gestures) "0" else "1")
+        // Only meaningful without the bar, but harmless with it, and setting it
+        // every time means a later switch to gestural mode cannot surprise.
+        val scale = if (gestures) "0.6" else "0"
+        out += "adb shell settings put secure back_gesture_inset_scale_left $scale"
+        out += "adb shell settings put secure back_gesture_inset_scale_right $scale"
+        return out
+    }
+}
diff --git a/app/src/main/java/dev/equwal/assistkey/native/ViwoodsBridge.kt b/app/src/main/java/dev/equwal/assistkey/native/ViwoodsBridge.kt
index ac8232d..b9b1945 100644
--- a/app/src/main/java/dev/equwal/assistkey/native/ViwoodsBridge.kt
+++ b/app/src/main/java/dev/equwal/assistkey/native/ViwoodsBridge.kt
@@ -76,8 +76,25 @@ object ViwoodsBridge {
     fun hidesFromFilter(c: Context, key: HwKey): Boolean =
         VOLUME_SLOT.containsKey(key) && read(c, key) != null
 
-    /** What the firmware does with the AI key when nothing consumes the press. */
-    fun aiTarget(c: Context): String = read(c, HwKey.AI) ?: Presets.STOCK_AI_KEY
+    private const val AI_ENTRY = "dev.equwal.assistkey.channel.AiKeyActivity"
+
+    /**
+     * What should open when an AI press is handed back to the firmware. Never
+     * our own entry point: that would be a loop.
+     */
+    fun aiTarget(c: Context): String =
+        read(c, HwKey.AI)?.takeUnless { aiHookedToUs(c) } ?: Presets.STOCK_AI_KEY
+
+    /** True when the firmware sends the AI key straight to this app. */
+    fun aiHookedToUs(c: Context): Boolean =
+        read(c, HwKey.AI)?.let { ComponentName.unflattenFromString(it) } ==
+            ComponentName(c.packageName, AI_ENTRY)
+
+    fun aiHookCommand(c: Context): String =
+        "adb shell settings put system " + AI_KEY + " " + c.packageName + "/" + AI_ENTRY
+
+    fun aiUnhookCommand(): String =
+        "adb shell settings put system " + AI_KEY + " " + Presets.STOCK_AI_KEY
 
     /** The command that hands a volume key back to the input pipeline. */
     fun unsetCommand(key: HwKey): String? =
@@ -86,7 +103,7 @@ object ViwoodsBridge {
     /** Human description of whatever the firmware currently has bound. */
     fun describe(c: Context, key: HwKey): String {
         val v = read(c, key) ?: return "Not set"
-        if (key == HwKey.AI) return shortName(c, v)
+        if (key == HwKey.AI) return if (aiHookedToUs(c)) "AssistKey" else shortName(c, v)
 
         val label = volumeTokens[v] ?: return shortName(c, v)
         if (v != TOKEN_APP) return label
diff --git a/app/src/main/java/dev/equwal/assistkey/store/Bindings.kt b/app/src/main/java/dev/equwal/assistkey/store/Bindings.kt
index c5c2e79..f5ee943 100644
--- a/app/src/main/java/dev/equwal/assistkey/store/Bindings.kt
+++ b/app/src/main/java/dev/equwal/assistkey/store/Bindings.kt
@@ -15,17 +15,26 @@ class Bindings(private val map: Map<Trigger, ActionSpec>) {
     private val active: Map<Trigger, ActionSpec> =
         map.filterValues { it.kind != ActionKind.PASS_THROUGH }
 
+    /**
+     * What the gesture engine may know about. Anything involving Power is kept
+     * from it: Power never reaches the key filter, so an engine that believed
+     * in a Power chord would hold every volume press back waiting for a
+     * partner that cannot arrive. Power triggers are served by the channels
+     * and by [powerCombo] instead.
+     */
+    private val engine: Set<Trigger> = active.keys.filter { HwKey.POWER !in it.keys }.toSet()
+
     private val tapsByKeys: Map<Set<HwKey>, Int> =
-        active.keys.filter { it.type == GestureType.TAP }
+        engine.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()
+        engine.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 ->
+        engine.filter { it.isChord }.forEach { t ->
             t.keys.forEach { k -> merge(k, t.keys - k) { a, b -> a + b } }
         }
     }
@@ -35,6 +44,12 @@ class Bindings(private val map: Map<Trigger, ActionSpec>) {
     fun chordPartners(key: HwKey): Set<HwKey> = partners[key] ?: emptySet()
 
     operator fun get(trigger: Trigger): ActionSpec? = active[trigger]
+
+    /** The action for "hold Power, then press [key]", if there is one. */
+    fun powerCombo(key: HwKey): ActionSpec? = active[Trigger.powerThen(key)]
+
+    val hasPowerCombos: Boolean =
+        HwKey.interceptable.any { active.containsKey(Trigger.powerThen(it)) }
     fun isBound(trigger: Trigger): Boolean = trigger in active
 
     /** Including PASS_THROUGH entries, for the UI. */
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/ChordActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/ChordActivity.kt
index 9f8445f..2d1b891 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/ChordActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/ChordActivity.kt
@@ -65,7 +65,7 @@ class ChordActivity : Activity() {
     /** Anything already bound, so a chord is easy to find again. */
     private fun existing(col: LinearLayout) {
         val sets = Store.bindings(this).all().keys
-            .filter { it.isChord }
+            .filter { it.isChord && dev.equwal.assistkey.model.HwKey.POWER !in it.keys }
             .map { it.keys }
             .distinct()
         if (sets.isEmpty()) return
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 4ef74bb..12b8132 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt
@@ -183,6 +183,11 @@ class MainActivity : Activity() {
 
         col.row("Power", powerSummary()) { startActivity(Intent(this, PowerActivity::class.java)) }
 
+        col.row(
+            "Navigation",
+            "Button bar, swipe gestures, Power key combinations - in any mix"
+        ) { startActivity(Intent(this, NavigationActivity::class.java)) }
+
         col.row(
             "Two-key combinations",
             "Hold one key and press another - any pair or larger set"
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/NavigationActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/NavigationActivity.kt
new file mode 100644
index 0000000..8d59d67
--- /dev/null
+++ b/app/src/main/java/dev/equwal/assistkey/ui/NavigationActivity.kt
@@ -0,0 +1,257 @@
+package dev.equwal.assistkey.ui
+
+import android.app.Activity
+import android.content.Context
+import android.widget.LinearLayout
+import android.widget.Toast
+import dev.equwal.assistkey.channel.Channel
+import dev.equwal.assistkey.channel.Channels
+import dev.equwal.assistkey.model.ActionKind
+import dev.equwal.assistkey.model.ActionSpec
+import dev.equwal.assistkey.model.GlobalAction
+import dev.equwal.assistkey.model.HwKey
+import dev.equwal.assistkey.model.Trigger
+import dev.equwal.assistkey.native.NavNative
+import dev.equwal.assistkey.native.ViwoodsBridge
+import dev.equwal.assistkey.store.Store
+import dev.equwal.assistkey.ui.Ui.button
+import dev.equwal.assistkey.ui.Ui.check
+import dev.equwal.assistkey.ui.Ui.code
+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
+
+/**
+ * How you get around the reader: the button bar, swipe gestures, Power key
+ * combinations, in any mix.
+ *
+ * The three are not equals. Power combinations are this app's own and are
+ * switched here. The bar and the gestures belong to the system, and Android
+ * lets no ordinary app switch either - so for those this screen works out what
+ * has to change, shows where things stand now, and hands over the commands.
+ */
+class NavigationActivity : Activity() {
+
+    private data class Setup(
+        val name: String,
+        val blurb: String,
+        val buttons: Boolean,
+        val gestures: Boolean,
+        val keys: Boolean
+    )
+
+    private val setups = listOf(
+        Setup("Buttons only", "Three-button bar at the bottom. No gestures at all.", true, false, false),
+        Setup("Gestures only", "Swipes, no button bar.", false, true, false),
+        Setup("Power key combos only", "No bar and no gestures - the whole screen is yours.", false, false, true),
+        Setup("Power key combos + buttons", "Bar and keys, no gestures.", true, false, true),
+        Setup("Power key combos + buttons + gestures", "Everything at once.", true, true, true)
+    )
+
+    private var redrawnForInsets = false
+
+    override fun onResume() {
+        super.onResume()
+        build()
+    }
+
+    // ---- wanted state, remembered ------------------------------------------------
+
+    private fun prefs() = getSharedPreferences("assistkey_nav", Context.MODE_PRIVATE)
+
+    private fun wanted(name: String, fallback: Boolean): Boolean =
+        if (prefs().contains(name)) prefs().getBoolean(name, fallback) else fallback
+
+    private fun choose(buttons: Boolean, gestures: Boolean, keys: Boolean) {
+        if (!buttons && !gestures && !keys) {
+            Toast.makeText(this, "That would leave no way to navigate at all", Toast.LENGTH_LONG).show()
+            build()
+            return
+        }
+        prefs().edit()
+            .putBoolean("buttons", buttons)
+            .putBoolean("gestures", gestures)
+            .putBoolean("keys", keys)
+            .apply()
+        applyKeys(keys)
+        build()
+    }
+
+    // ---- the part this app owns -----------------------------------------------------
+
+    private val defaults = listOf(
+        HwKey.VOL_UP to GlobalAction.BACK,
+        HwKey.AI to GlobalAction.HOME,
+        HwKey.VOL_DOWN to GlobalAction.RECENTS
+    )
+
+    private fun keysOn(): Boolean = Store.bindings(this).hasPowerCombos
+
+    /**
+     * On: make sure the two channels that carry a Power combination are ticked,
+     * and fill any empty slot with a navigation default. Slots the user has
+     * already bound are theirs and are left alone. Off: clear the three slots.
+     */
+    private fun applyKeys(on: Boolean) {
+        if (on) {
+            Channels.setEnabled(this, Channel.ACCESSIBILITY, true)
+            Channels.setEnabled(this, Channel.ASSISTANT, true)
+            val b = Store.bindings(this)
+            defaults.forEach { (key, action) ->
+                val t = Trigger.powerThen(key)
+                if (!b.isBound(t)) {
+                    Store.bind(this, t, ActionSpec(ActionKind.GLOBAL, action.name, action.label))
+                }
+            }
+        } else {
+            HwKey.interceptable.forEach { Store.bind(this, Trigger.powerThen(it), ActionSpec.PASS) }
+        }
+    }
+
+    // ---- screen ---------------------------------------------------------------------------
+
+    private fun build() {
+        val buttonsNow = NavNative.buttonsShowing(this)
+        if (buttonsNow == null && !redrawnForInsets) {
+            // Insets only exist once the window is attached.
+            redrawnForInsets = true
+            window.decorView.post { if (!isFinishing) build() }
+        }
+        val gesturesNow = NavNative.gesturesOn(this)
+        val keysNow = keysOn()
+
+        val wantButtons = wanted("buttons", buttonsNow ?: true)
+        val wantGestures = wanted("gestures", gesturesNow ?: true)
+        val wantKeys = wanted("keys", keysNow)
+
+        val col = Ui.page(this)
+        col.title("Navigation")
+        col.note(
+            "Three ways to get around, in any mix: the button bar, swipe " +
+                "gestures, and Power key combinations."
+        )
+
+        col.header("Pick a setup")
+        setups.forEach { s ->
+            val current = s.buttons == wantButtons && s.gestures == wantGestures && s.keys == wantKeys
+            col.row((if (current) "* " else "") + s.name, s.blurb) {
+                choose(s.buttons, s.gestures, s.keys)
+            }
+        }
+
+        col.header("Or mix your own")
+        col.check("Button bar", "Back, Home and Recents along the bottom edge", wantButtons) {
+            choose(it, wantGestures, wantKeys)
+        }
+        col.check(
+            "Swipe gestures",
+            "Swipe up from the bottom edge for Home; without the bar, swipe in " +
+                "from a side edge for Back",
+            wantGestures
+        ) { choose(wantButtons, it, wantKeys) }
+        col.check(
+            "Power key combinations",
+            "Hold Power, then press Volume up, the AI key or Volume down",
+            wantKeys
+        ) { choose(wantButtons, wantGestures, it) }
+
+        if (wantKeys) keys(col)
+        system(col, wantButtons, wantGestures, buttonsNow, gesturesNow)
+    }
+
+    private fun keys(col: LinearLayout) {
+        col.header("Power key combinations")
+        val b = Store.bindings(this)
+        HwKey.interceptable.forEach { key ->
+            val t = Trigger.powerThen(key)
+            col.row("Power, then " + Ui.inSentence(key), b.raw(t).describe()) {
+                startActivity(ActionPickerActivity.intent(this, t))
+            }
+        }
+
+        val a11y = Channels.isSatisfied(this, Channel.ACCESSIBILITY)
+        val assistant = Channels.isSatisfied(this, Channel.ASSISTANT)
+        if (a11y && assistant) {
+            col.note(
+                "Ready. Hold Power for about half a second, then press the " +
+                    "second key while still holding."
+            )
+        }
+        if (!assistant) {
+            col.note("Holding Power has to reach this app: make AssistKey the digital assistant.")
+            col.button("Set up digital assistant") {
+                Channels.safeStart(this, Channels.claimIntent(this, Channel.ASSISTANT))
+            }
+        }
+        if (!a11y) {
+            col.note(
+                "The second key is read by the accessibility key filter, which is " +
+                    "off. Turn it on from the main screen."
+            )
+        }
+        if (!ViwoodsBridge.aiHookedToUs(this) && b.powerCombo(HwKey.AI) != null) {
+            col.note(
+                "Power, then the AI key will also open the firmware's AI screen " +
+                    "until the AI key hook points at AssistKey. See Advanced > " +
+                    "Firmware key hooks."
+            )
+        }
+        HwKey.interceptable.filter { ViwoodsBridge.hidesFromFilter(this, it) }.forEach { k ->
+            col.note(
+                "A firmware hook is hiding " + k.label.lowercase() + " from this app, so its " +
+                    "combination cannot fire. See Advanced > Firmware key hooks."
+            )
+        }
+        col.note(
+            "Back, Home and Recents on these combinations keep working even if " +
+                "the app is locked, so a reader with no bar and no gestures can " +
+                "never be left without a way out."
+        )
+    }
+
+    private fun system(
+        col: LinearLayout,
+        wantButtons: Boolean,
+        wantGestures: Boolean,
+        buttonsNow: Boolean?,
+        gesturesNow: Boolean?
+    ) {
+        col.header("Button bar and gestures")
+        col.row("Button bar: " + now(buttonsNow, "showing", "hidden") + want(buttonsNow, wantButtons), null, enabled = false)
+        col.row("Swipe-up gesture: " + now(gesturesNow, "on", "off") + want(gesturesNow, wantGestures), null, enabled = false)
+
+        val settled = buttonsNow == wantButtons && gesturesNow == wantGestures
+        if (settled) {
+            col.note("The system already matches. Nothing more to do.")
+            return
+        }
+        col.note(
+            "Android does not let apps switch these, so they are set once from a " +
+                "computer with the reader plugged in and USB debugging on. They " +
+                "survive restarts. Run them in this order:"
+        )
+        col.code(NavNative.commands(wantButtons, wantGestures).joinToString("\n"))
+        col.note(
+            "The repository has tools/nav-mode, which runs the same commands: " +
+                "nav-mode " + (if (wantButtons) "buttons" else "nobuttons") + " " +
+                (if (wantGestures) "gestures" else "nogestures")
+        )
+        if (!wantButtons && !wantGestures) {
+            col.note(
+                "With no bar and no gestures, set up and try the Power " +
+                    "combinations before running these. Power + Volume up still " +
+                    "opens the power menu whatever happens."
+            )
+        }
+    }
+
+    private fun now(v: Boolean?, yes: String, no: String): String = when (v) {
+        true -> yes
+        false -> no
+        null -> "unknown"
+    }
+
+    private fun want(now: Boolean?, wanted: Boolean): String =
+        if (now == wanted) "" else "  -  you want it " + (if (wanted) "on" else "off")
+}
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/PowerActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/PowerActivity.kt
index 65a5fb4..c4b8d6a 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/PowerActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/PowerActivity.kt
@@ -5,6 +5,8 @@ import android.widget.LinearLayout
 import android.widget.Toast
 import dev.equwal.assistkey.channel.Channel
 import dev.equwal.assistkey.channel.Channels
+import dev.equwal.assistkey.model.HwKey
+import dev.equwal.assistkey.model.Trigger
 import dev.equwal.assistkey.native.PowerNative
 import dev.equwal.assistkey.store.Store
 import dev.equwal.assistkey.ui.Ui.code
@@ -58,9 +60,30 @@ class PowerActivity : Activity() {
         shortPress(col)
         doublePress(col)
         longPress(col)
+        powerThen(col)
         escapeHatch(col)
     }
 
+    // ---- hold Power, then press a key ---------------------------------------
+
+    private fun powerThen(col: LinearLayout) {
+        col.header("Hold Power, then press a key")
+        col.note(
+            "Keep Power held for a moment, then press another key before letting " +
+                "go. It needs the digital assistant channel and the accessibility " +
+                "key filter, both on. While any of these is bound, the plain " +
+                "press-and-hold action above waits a second to see whether a key " +
+                "follows."
+        )
+        val b = Store.bindings(this)
+        HwKey.interceptable.forEach { key ->
+            val t = Trigger.powerThen(key)
+            col.row("Power, then " + Ui.inSentence(key), b.raw(t).describe()) {
+                startActivity(ActionPickerActivity.intent(this, t))
+            }
+        }
+    }
+
     // ---- short press: firmware only ---------------------------------------
 
     private fun shortPress(col: LinearLayout) {
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 265ebaf..7c9a9ee 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/TriggerListActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/TriggerListActivity.kt
@@ -54,6 +54,19 @@ class TriggerListActivity : Activity() {
             )
         }
 
+        if (HwKey.AI in keys) {
+            col.note(
+                if (ViwoodsBridge.aiHookedToUs(this)) {
+                    "The firmware sends the AI key straight to AssistKey, so only " +
+                        "taps of the AI key on its own can fire."
+                } else {
+                    "The firmware also opens its AI screen on every AI key press, " +
+                        "underneath whatever is bound here. Advanced > Firmware key " +
+                        "hooks shows how to stop that."
+                }
+            )
+        }
+
         val b = Store.bindings(this)
         col.header("Taps")
         (1..Trigger.MAX_TAPS).forEach { n ->
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/Ui.kt b/app/src/main/java/dev/equwal/assistkey/ui/Ui.kt
index 51aca82..1610a2f 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/Ui.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/Ui.kt
@@ -32,6 +32,10 @@ object Ui {
     val DIM = Color.rgb(90, 90, 90)
     val RULE = Color.rgb(200, 200, 200)
 
+    /** A key's name mid-sentence: "volume up", but never "ai key". */
+    fun inSentence(key: dev.equwal.assistkey.model.HwKey): String =
+        if (key == dev.equwal.assistkey.model.HwKey.AI) "the AI key" else key.label.lowercase()
+
     fun Context.dp(v: Int): Int = TypedValue.applyDimension(
         TypedValue.COMPLEX_UNIT_DIP, v.toFloat(), resources.displayMetrics
     ).toInt()
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/ViwoodsActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/ViwoodsActivity.kt
index ab15c51..bf18155 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/ViwoodsActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/ViwoodsActivity.kt
@@ -41,10 +41,28 @@ class ViwoodsActivity : Activity() {
         col.row("Firmware setting: " + ViwoodsBridge.describe(this, key), null, enabled = false)
 
         if (key == HwKey.AI) {
-            col.note(
-                "AssistKey sees the AI key whatever this says. The setting only " +
-                    "decides what opens when a press is not bound to anything."
-            )
+            if (ViwoodsBridge.aiHookedToUs(this)) {
+                col.note(
+                    "The firmware hands every AI key press straight to AssistKey. " +
+                        "Taps, and Power then the AI key, work cleanly. Press-and-hold " +
+                        "and combinations with the volume keys are not available this " +
+                        "way. To give the key back to the firmware:"
+                )
+                col.code(ViwoodsBridge.aiUnhookCommand())
+            } else {
+                col.note(
+                    "With this setting the firmware opens its own AI screen on every " +
+                        "press, whether or not AssistKey acted on it - so a binding " +
+                        "fires on top of that screen. To own the key cleanly, point " +
+                        "the hook at AssistKey once from a computer:"
+                )
+                col.code(ViwoodsBridge.aiHookCommand(this))
+                col.note(
+                    "Taps then work without the AI screen appearing. Press-and-hold " +
+                        "and combinations with the volume keys stop working for this " +
+                        "key, because the firmware no longer lets apps see it."
+                )
+            }
             return
         }
 
diff --git a/gradle.properties b/gradle.properties
index dfb31f2..e87ddb4 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -6,8 +6,8 @@ android.nonTransitiveRClass=true
 kotlin.code.style=official
 
 # Release identity. versionCode must rise with every upload to Play.
-assistkey.versionName=0.0.1-alpha
-assistkey.versionCode=1
+assistkey.versionName=0.0.2-alpha
+assistkey.versionCode=2
 
 # The free beta ends on this date (UTC) at the latest. It is the backstop for
 # installs that cannot reach Google Play; the real switch is the
diff --git a/play/LISTING.md b/play/LISTING.md
index 163b76f..04cc6a5 100644
--- a/play/LISTING.md
+++ b/play/LISTING.md
@@ -30,6 +30,7 @@ AI key and volume keys
 • A key with only a single-tap binding fires instantly - you only wait out a double-tap window on keys where you asked for a double tap
 
 Power button
+• Hold Power, then press another key: three extra combinations - Back, Home and Recents by default, so you can hide the navigation bar and keep the whole screen for reading
 • Press and hold: run any action (AssistKey becomes your digital assistant app)
 • Double press: run any action (AssistKey becomes your default camera app)
 • Short press: choose the firmware behaviour
@@ -60,6 +61,7 @@ GOOD TO KNOW
 
 • Made for the Viwoods AiPaper Reader. The volume and power features work on most Android 12+ devices; the AI key and Viwoods actions are specific to Viwoods hardware.
 • Android does not let any app see the power button directly, so power gestures are limited to short press, double press and hold.
+• Hiding the navigation bar, switching system gestures off, and giving AssistKey sole control of the AI key are one-time steps done from a computer over USB. Android does not allow any app to change them. The app shows the exact commands.
 • If a volume key does not respond, the device's own key settings may be holding on to it. The built-in key tester shows exactly which keys AssistKey can see, and the Firmware key hooks screen shows the fix.
 
 PRICE
diff --git a/tools/ai-key b/tools/ai-key
new file mode 100755
index 0000000..1e4f704
--- /dev/null
+++ b/tools/ai-key
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+# Points the firmware's AI key hook at AssistKey, or hands it back.
+#
+# With the stock hook the firmware opens its AI screen on every press, whether
+# or not an app acted on the key. Hooked, each press goes straight to AssistKey
+# and nothing else opens. Android lets no app change this setting, hence adb.
+#
+#   tools/ai-key hook      AssistKey owns the AI key (taps; Power, then AI key)
+#   tools/ai-key unhook    back to the firmware default (AI crop)
+#   tools/ai-key status
+set -euo pipefail
+export MSYS_NO_PATHCONV=1
+OURS=dev.equwal.assistkey/dev.equwal.assistkey.channel.AiKeyActivity
+STOCK=com.viwoods.launcher/com.viwoods.libfloating.activity.ScreenCaptureActivity   # factory default; pick another in the reader's own key settings
+case "${1:-}" in
+  hook)   adb shell settings put system CustomAiKey "$OURS";;
+  unhook) adb shell settings put system CustomAiKey "$STOCK";;
+  status) ;;
+  *) sed -n '2,11p' "$0"; exit 2;;
+esac
+echo "CustomAiKey = $(adb shell settings get system CustomAiKey | tr -d '\r')"
diff --git a/tools/nav-mode b/tools/nav-mode
new file mode 100755
index 0000000..c13e35e
--- /dev/null
+++ b/tools/nav-mode
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+# Switches the reader's system navigation: the three-button bar and the swipe
+# gestures. Android lets no app do this, so it is done over adb, once; it
+# survives restarts. Power key combinations are set inside AssistKey instead.
+#
+#   tools/nav-mode buttons   nogestures     three-button bar, no gestures at all
+#   tools/nav-mode nobuttons gestures       gestures only
+#   tools/nav-mode nobuttons nogestures     neither: Power key combinations only
+#   tools/nav-mode buttons   gestures       both
+#   tools/nav-mode status
+#
+# Order matters: switching the overlay makes SystemUI forget
+# disable_gesture_bottom, so the overlay goes first and the setting after.
+set -euo pipefail
+export MSYS_NO_PATHCONV=1
+OVERLAY=com.android.internal.systemui.navbar
+
+status() {
+  local mode gest
+  mode=$(adb shell settings get secure navigation_mode | tr -d '\r')
+  gest=$(adb shell settings get system disable_gesture_bottom | tr -d '\r')
+  echo "button bar:       $([ "$mode" = "2" ] && echo hidden || echo showing)"
+  echo "swipe-up gesture: $([ "$gest" = "1" ] && echo off || echo on)"
+  echo "edge-swipe back:  $([ "$mode" = "2" ] && echo "inset scale $(adb shell settings get secure back_gesture_inset_scale_left | tr -d '\r')" || echo "n/a with the bar")"
+}
+
+[ "${1:-}" = "status" ] && { status; exit 0; }
+[ $# -eq 2 ] || { sed -n '2,14p' "$0"; exit 2; }
+
+case "$1" in buttons) cat=threebutton;; nobuttons) cat=gestural;; *) echo "first argument: buttons | nobuttons"; exit 2;; esac
+case "$2" in gestures) off=0; scale=0.6;; nogestures) off=1; scale=0;; *) echo "second argument: gestures | nogestures"; exit 2;; esac
+
+if [ "$cat" = gestural ] && [ "$off" = 1 ]; then
+  echo "No bar and no gestures: make sure AssistKey's Power key combinations work first."
+  echo "To undo from a computer: tools/nav-mode buttons gestures"
+fi
+
+adb shell cmd overlay enable-exclusive --category "$OVERLAY.$cat"
+sleep 5
+adb shell settings put system disable_gesture_bottom $((1 - off))   # nudge the observer
+sleep 1
+adb shell settings put system disable_gesture_bottom "$off"
+adb shell settings put secure back_gesture_inset_scale_left "$scale"
+adb shell settings put secure back_gesture_inset_scale_right "$scale"
+sleep 1
+status
diff --git a/tools/nav-mode.ps1 b/tools/nav-mode.ps1
new file mode 100644
index 0000000..ab18d01
--- /dev/null
+++ b/tools/nav-mode.ps1
@@ -0,0 +1,27 @@
+# PowerShell twin of tools/nav-mode. See that file for what and why.
+#   .\tools\nav-mode.ps1 buttons nogestures
+#   .\tools\nav-mode.ps1 status
+param([string]$Buttons = "", [string]$Gestures = "")
+
+function Show-Status {
+    $mode = (adb shell settings get secure navigation_mode).Trim()
+    $gest = (adb shell settings get system disable_gesture_bottom).Trim()
+    "button bar:       " + $(if ($mode -eq "2") { "hidden" } else { "showing" })
+    "swipe-up gesture: " + $(if ($gest -eq "1") { "off" } else { "on" })
+}
+
+if ($Buttons -eq "status") { Show-Status; exit 0 }
+$cat = switch ($Buttons) { "buttons" { "threebutton" } "nobuttons" { "gestural" } default { $null } }
+$off = switch ($Gestures) { "gestures" { 0 } "nogestures" { 1 } default { $null } }
+if ($null -eq $cat -or $null -eq $off) { "usage: nav-mode.ps1 buttons|nobuttons gestures|nogestures   (or: status)"; exit 2 }
+$scale = if ($off -eq 1) { "0" } else { "0.6" }
+
+adb shell cmd overlay enable-exclusive --category "com.android.internal.systemui.navbar.$cat"
+Start-Sleep -Seconds 5
+adb shell settings put system disable_gesture_bottom (1 - $off)
+Start-Sleep -Seconds 1
+adb shell settings put system disable_gesture_bottom $off
+adb shell settings put secure back_gesture_inset_scale_left $scale
+adb shell settings put secure back_gesture_inset_scale_right $scale
+Start-Sleep -Seconds 1
+Show-Status