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 ed07fa386b703539875fcd429819879f64b1e670
truex <truex@equwal.com>
2026-09-21 00:39:00 -0700

Add ready-made menus, a permissions screen and recents polish; tone down shell access (0.0.7-alpha)

Ready-made menus. The action list offers menus that bind in one tap:
Brightness (full build), Navigation, System, Sound and media, Page turning,
and Viwoods AI on Viwoods devices. Each is an ordinary menu after it is bound.

Brightness up and down. Two more light actions, for the system brightness. A
step is a quarter of the present value and at least 5, and stays in range;
tests cover every value from 0 to 255.

Permissions screen. Every grant the app can use, with its purpose and state.
"Set up what is missing" opens the grant screens one after the other. It
opens once by itself on the first run and can be run again at any time. A
permission dialog reports twice on return, as a result and as a resume, so a
second step close behind the first is dropped.

The accessibility disclosure is now one shared text. It names voice typing and
the app-in-front note, which the old text did not.

Recent apps. One swipe moves one card. A row of icons under the cards shows
every recent app and marks the one in the middle. The heading says which app
and its position. Each card that can be closed says "Swipe up to close", and
"Close all but <app>" sits beside "Close all".

Shell access and the extra-dim light move under Advanced, and shell access is
not counted as a missing permission. They serve the few devices with Shizuku
or root, and the app is complete without them.

Store listing: the title is "AssistKey: E-Ink Key Remap". No device maker is
named in the title, and the description has a not-affiliated line. A maker
name at the front of a title reads as an official app and can be removed as
impersonation. The checklist records the advice to price at US$4.99; the owner
has not decided.

 CHANGELOG.md                                       |  23 +++
 app/src/main/AndroidManifest.xml                   |   1 +
 .../java/dev/equwal/assistkey/display/ExtraDim.kt  |  26 ++-
 .../dev/equwal/assistkey/home/RecentsActivity.kt   | 132 +++++++++++---
 .../main/java/dev/equwal/assistkey/menu/Menu.kt    |  48 ++++++
 .../java/dev/equwal/assistkey/model/ActionSpec.kt  |   2 +
 .../equwal/assistkey/ui/AccessibilityDisclosure.kt |  46 +++++
 .../equwal/assistkey/ui/ActionPickerActivity.kt    |   9 +-
 .../java/dev/equwal/assistkey/ui/MainActivity.kt   |  49 ++----
 .../dev/equwal/assistkey/ui/PermissionsActivity.kt | 192 +++++++++++++++++++++
 .../java/dev/equwal/assistkey/ui/PowerActivity.kt  |  39 ++---
 .../equwal/assistkey/display/ExtraDimStepTest.kt   |  17 ++
 .../equwal/assistkey/model/ActionSpecNameTest.kt   |   2 +
 play/CHECKLIST.md                                  |   8 +-
 play/DECLARATIONS.md                               |   4 +-
 play/LISTING.md                                    |  17 +-
 16 files changed, 537 insertions(+), 78 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0c054e2..b1e82ad 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,29 @@ Version code 7.
   presses, which collides with a 5-tap binding. The Power screen now has a
   switch for it (`emergency_gesture_enabled`). The app never turns it off by
   itself. The switch shows only where the app can write the setting.
+- **Permissions screen.** Every grant the app can use, with what it is for and
+  whether it is granted. "Set up what is missing" opens the grant screens one
+  after the other. It opens once by itself on the first run and can be run
+  again at any time from Setup > Permissions.
+- The accessibility disclosure is one shared text now, and it names voice
+  typing and the app-in-front note, which the old text did not.
+- Recent apps: each card says "Swipe up to close", and there is "Close all but
+  <app>" beside "Close all".
+- Store listing: title is now "AssistKey: E-Ink Key Remap", with no maker name
+  in the title and a not-affiliated line in the description.
+- Shell access and the extra-dim light are under Advanced now, and are not
+  counted as missing permissions. They are for the few devices with Shizuku or
+  root; the app is complete without them.
+- **Ready-made menus.** The action list offers menus that can be bound in one
+  tap: Brightness (`full` build), Navigation, System, Sound and media, Page
+  turning, and Viwoods AI on Viwoods devices. Each is an ordinary menu after it
+  is bound, and the menu editor can change it.
+- **Brightness up and down.** Two more light actions, for the system
+  brightness. A step is a quarter of the present value and at least 5.
+- **Recent apps: easier to read, nicer to scroll.** One swipe moves one card, in
+  the direction of the swipe. A row of icons under the cards shows every recent
+  app at once and marks the one in the middle; a tap on an icon goes to its
+  card. The heading says which app and "2 of 12".
 - The three light actions are now named Extra dim toggle, Extra dim darker and
   Extra dim brighter. A binding made by 0.0.6 shows the new name with no change.
 
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c94933f..34adda6 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -81,6 +81,7 @@
         <activity android:name=".ui.DisplayActivity" android:exported="false" />
         <activity android:name=".ui.VoiceActivity" android:exported="false" />
         <activity android:name=".ui.BackupActivity" android:exported="false" />
+        <activity android:name=".ui.PermissionsActivity" android:exported="false" />
         <activity android:name=".menu.MenuEditActivity" android:exported="false" />
         <!-- The action menu: a box over the app in front, in a task of its own. -->
         <activity
diff --git a/app/src/main/java/dev/equwal/assistkey/display/ExtraDim.kt b/app/src/main/java/dev/equwal/assistkey/display/ExtraDim.kt
index ad18802..9bb2eea 100644
--- a/app/src/main/java/dev/equwal/assistkey/display/ExtraDim.kt
+++ b/app/src/main/java/dev/equwal/assistkey/display/ExtraDim.kt
@@ -65,8 +65,32 @@ object ExtraDim {
      */
     fun toggled(level: Int, levels: List<Int>): Int = if (level > 0) 0 else levels.lastOrNull() ?: 0
 
-    /** Runs a key action: "darker", "brighter" or "toggle". */
+    /**
+     * One step of the system brightness, 0..255. A step is a quarter of the
+     * present value and at least 5, so steps are fine where the light is low and
+     * coarse where it is high. The result stays between [floor] and 255.
+     */
+    fun systemStep(value: Int, up: Boolean, floor: Int): Int {
+        val step = maxOf(5, value / 4)
+        return (if (up) value + step else value - step).coerceIn(floor, 255)
+    }
+
+    private fun systemBrightness(c: Context, up: Boolean, done: (Boolean) -> Unit) {
+        // Brighter from an extra-dim level means: leave extra-dim first.
+        if (up && level(c) > 0) return set(c, 0, done)
+        Shell.run("settings get system screen_brightness") { r ->
+            val now = r.output.trim().toIntOrNull() ?: return@run done(false)
+            val next = systemStep(now, up, Device.brightnessFloor ?: 1)
+            Shell.run("settings put system screen_brightness_mode 0; settings put system screen_brightness $next") {
+                done(it.ok)
+            }
+        }
+    }
+
+    /** Runs a key action: "toggle", "darker", "brighter", "system_up" or "system_down". */
     fun act(c: Context, what: String, done: (Boolean) -> Unit = {}) {
+        if (what == "system_up") return systemBrightness(c, true, done)
+        if (what == "system_down") return systemBrightness(c, false, done)
         val now = level(c)
         val next = when (what) {
             "darker" -> darker(now, levels())
diff --git a/app/src/main/java/dev/equwal/assistkey/home/RecentsActivity.kt b/app/src/main/java/dev/equwal/assistkey/home/RecentsActivity.kt
index 93a35e5..4754383 100644
--- a/app/src/main/java/dev/equwal/assistkey/home/RecentsActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/home/RecentsActivity.kt
@@ -46,7 +46,12 @@ class RecentsActivity : Activity() {
 
     private var row: LinearLayout? = null
     private var scroller: HorizontalScrollView? = null
+    private var strip: LinearLayout? = null
+    private var heading: TextView? = null
+    private var closeOthers: android.widget.Button? = null
+    private var names: List<String> = emptyList()
     private var cardStep = 0
+    private var index = 0
 
     override fun onResume() {
         super.onResume()
@@ -70,10 +75,12 @@ class RecentsActivity : Activity() {
     private fun show(recents: List<Apps.Recent>, closable: Boolean) {
         val screenW = resources.displayMetrics.widthPixels
         val screenH = resources.displayMetrics.heightPixels
-        val cardW = (screenW * 0.72f).toInt()
-        val cardH = (screenH * 0.62f).toInt()
+        val cardW = (screenW * 0.70f).toInt()
+        val cardH = (screenH * 0.56f).toInt()
         val gap = dp(16)
         cardStep = cardW + gap
+        names = recents.map { it.app.label }
+        index = index.coerceIn(0, maxOf(0, recents.size - 1))
 
         val root = LinearLayout(this).apply {
             orientation = LinearLayout.VERTICAL
@@ -84,13 +91,15 @@ class RecentsActivity : Activity() {
                 insets
             }
         }
-        root.addView(TextView(this).apply {
+        heading = TextView(this).apply {
             text = if (recents.isEmpty()) "Nothing recent" else "Recent apps"
             setTextColor(Color.BLACK)
             setTextSize(TypedValue.COMPLEX_UNIT_SP, 22f)
             typeface = Typeface.DEFAULT_BOLD
+            setSingleLine()
             setPadding(dp(20), dp(16), dp(20), dp(12))
-        })
+        }
+        root.addView(heading)
 
         val cards = LinearLayout(this).apply {
             orientation = LinearLayout.HORIZONTAL
@@ -107,9 +116,27 @@ class RecentsActivity : Activity() {
         row = cards
 
         val scroll = object : HorizontalScrollView(this) {
+            private var startX = 0
+
+            override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
+                if (ev.actionMasked == MotionEvent.ACTION_DOWN) startX = scrollX
+                return super.onInterceptTouchEvent(ev)
+            }
+
+            /**
+             * One swipe, one card. The row follows the finger, and on release it
+             * goes to the next card in the direction of the swipe, however far
+             * or fast the swipe was. A very short drag goes back where it was.
+             */
             override fun onTouchEvent(ev: MotionEvent): Boolean {
+                if (ev.actionMasked == MotionEvent.ACTION_DOWN) startX = scrollX
                 val handled = super.onTouchEvent(ev)
-                if (ev.actionMasked == MotionEvent.ACTION_UP || ev.actionMasked == MotionEvent.ACTION_CANCEL) snap()
+                if (ev.actionMasked == MotionEvent.ACTION_UP || ev.actionMasked == MotionEvent.ACTION_CANCEL) {
+                    val moved = scrollX - startX
+                    val from = Math.round(startX / cardStep.toFloat())
+                    val turn = if (abs(moved) < cardStep / 8) 0 else if (moved > 0) 1 else -1
+                    show(from + turn)
+                }
                 return handled
             }
 
@@ -123,34 +150,89 @@ class RecentsActivity : Activity() {
         scroller = scroll
         root.addView(scroll, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT))
 
+        // Every recent app at once, as icons. The one in the middle is marked,
+        // and a tap on an icon brings its card to the middle.
+        val icons = LinearLayout(this).apply {
+            orientation = LinearLayout.HORIZONTAL
+            gravity = Gravity.CENTER_HORIZONTAL
+            setPadding(dp(12), dp(14), dp(12), dp(4))
+        }
+        recents.forEachIndexed { i, r ->
+            icons.addView(
+                ImageView(this).apply {
+                    setImageDrawable(runCatching { packageManager.getApplicationIcon(r.app.pkg) }.getOrNull())
+                    contentDescription = r.app.label
+                    val p = dp(6)
+                    setPadding(p, p, p, p)
+                    setOnClickListener { show(i) }
+                },
+                LinearLayout.LayoutParams(dp(52), dp(52)).apply { marginEnd = dp(4) }
+            )
+        }
+        strip = icons
+        root.addView(
+            HorizontalScrollView(this).apply {
+                isHorizontalScrollBarEnabled = false
+                addView(icons, LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT))
+            },
+            LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
+        )
+
         root.addView(TextView(this).apply {
-            text = if (closable) "Tap a card to open it. Swipe a card up to close it."
+            text = if (closable) "Tap a card to open it.  Swipe a card up to close it."
             else "Tap a card to open it."
-            setTextColor(Color.rgb(70, 70, 70))
-            setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
+            setTextColor(Color.BLACK)
+            setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
             gravity = Gravity.CENTER_HORIZONTAL
-            setPadding(dp(20), dp(14), dp(20), dp(6))
+            setPadding(dp(20), dp(14), dp(20), dp(8))
         })
+        closeOthers = null
         if (closable && recents.isNotEmpty()) {
-            root.addView(
-                android.widget.Button(this).apply {
-                    text = "Close all"
+            val buttons = LinearLayout(this).apply {
+                orientation = LinearLayout.HORIZONTAL
+                gravity = Gravity.CENTER_HORIZONTAL
+            }
+            buttons.addView(android.widget.Button(this).apply {
+                text = "Close all"
+                setOnClickListener { close(recents.mapNotNull { it.taskId }) }
+            })
+            if (recents.size > 1) {
+                closeOthers = android.widget.Button(this).apply {
+                    // The name is filled in by show(i): it is the card in the middle.
                     setOnClickListener {
-                        Shell.runAll(recents.mapNotNull { it.taskId }.map { "am stack remove $it" }) { load() }
+                        close(recents.filterIndexed { i, _ -> i != index }.mapNotNull { it.taskId })
                     }
-                },
-                LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { gravity = Gravity.CENTER_HORIZONTAL }
-            )
+                }
+                buttons.addView(closeOthers)
+            }
+            root.addView(buttons, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT))
         }
         setContentView(root)
+        scroll.post { show(index) }
     }
 
-    /** Puts the nearest card in the middle, in one step. */
-    private fun snap() {
+    private fun close(taskIds: List<Int>) {
+        index = 0
+        Shell.runAll(taskIds.map { "am stack remove $it" }) { load() }
+    }
+
+    /** Puts card [i] in the middle, in one step, and says which one it is. */
+    private fun show(i: Int) {
         val s = scroller ?: return
-        if (cardStep <= 0) return
-        val index = ((s.scrollX + cardStep / 2) / cardStep).coerceIn(0, (row?.childCount ?: 1) - 1)
+        val count = row?.childCount ?: 0
+        if (cardStep <= 0 || count == 0) return
+        index = i.coerceIn(0, count - 1)
         s.scrollTo(index * cardStep, 0)
+        heading?.text = names.getOrElse(index) { "" } + "   " + (index + 1) + " of " + count
+        closeOthers?.text = "Close all but " + names.getOrElse(index) { "this one" }
+        strip?.let { icons ->
+            for (n in 0 until icons.childCount) {
+                icons.getChildAt(n).background = if (n != index) null else GradientDrawable().apply {
+                    setStroke(dp(2), Color.BLACK)
+                    cornerRadius = dp(10).toFloat()
+                }
+            }
+        }
     }
 
     private fun card(r: Apps.Recent, closable: Boolean): View {
@@ -190,6 +272,16 @@ class RecentsActivity : Activity() {
             setPadding(p, p, p, p)
         }
         box.addView(body, LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f))
+        if (closable && r.taskId != null) {
+            box.addView(View(this).apply { setBackgroundColor(Color.BLACK) }, LinearLayout.LayoutParams(MATCH_PARENT, dp(1)))
+            box.addView(TextView(this).apply {
+                text = "\u2191  Swipe up to close"
+                setTextColor(Color.BLACK)
+                setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
+                gravity = Gravity.CENTER_HORIZONTAL
+                setPadding(0, dp(8), 0, dp(8))
+            })
+        }
         r.taskId?.let { id ->
             Apps.snapshot(id) { picture ->
                 if (picture != null && !isFinishing) {
diff --git a/app/src/main/java/dev/equwal/assistkey/menu/Menu.kt b/app/src/main/java/dev/equwal/assistkey/menu/Menu.kt
index 71ea6a7..12884b4 100644
--- a/app/src/main/java/dev/equwal/assistkey/menu/Menu.kt
+++ b/app/src/main/java/dev/equwal/assistkey/menu/Menu.kt
@@ -1,7 +1,11 @@
 package dev.equwal.assistkey.menu
 
 import dev.equwal.assistkey.model.ActionKind
+import dev.equwal.assistkey.device.Device
 import dev.equwal.assistkey.model.ActionSpec
+import dev.equwal.assistkey.model.GlobalAction
+import dev.equwal.assistkey.model.Presets
+import dev.equwal.assistkey.shell.Shell
 import org.json.JSONArray
 
 /**
@@ -23,6 +27,50 @@ object Menu {
             .filter { it.kind != ActionKind.MENU && it.kind != ActionKind.PASS_THROUGH }
     }.getOrDefault(emptyList())
 
+    /**
+     * Ready-made menus, for the groups of actions that belong together. Each is
+     * an ordinary menu after it is bound, so the user can change it in the editor.
+     * A preset that this device or this build cannot run is not offered.
+     */
+    fun presets(c: android.content.Context): List<Pair<String, List<ActionSpec>>> {
+        fun global(a: GlobalAction) = ActionSpec(ActionKind.GLOBAL, a.name, a.label)
+        val out = ArrayList<Pair<String, List<ActionSpec>>>()
+
+        if (Shell.SUPPORTED) {
+            out += "Brightness" to listOf("toggle", "darker", "brighter", "system_up", "system_down")
+                .map { ActionSpec(ActionKind.DIM, it, "") }
+        }
+        out += "Navigation" to listOf(
+            global(GlobalAction.BACK),
+            global(GlobalAction.HOME_CLOSE_IME),
+            ActionSpec(ActionKind.LAUNCH_COMPONENT, c.packageName + "/dev.equwal.assistkey.home.RecentsActivity", "Recent apps (cards)"),
+            global(GlobalAction.NOTIFICATIONS),
+            global(GlobalAction.QUICK_SETTINGS)
+        )
+        out += "System" to listOf(
+            global(GlobalAction.SCREENSHOT),
+            global(GlobalAction.LOCK_SCREEN),
+            global(GlobalAction.POWER_DIALOG),
+            ActionSpec(ActionKind.VOICE, "", "Voice typing")
+        )
+        out += "Sound and media" to listOf(
+            ActionSpec(ActionKind.MEDIA, "play_pause", "Play / pause"),
+            ActionSpec(ActionKind.MEDIA, "next", "Next track"),
+            ActionSpec(ActionKind.MEDIA, "previous", "Previous track"),
+            ActionSpec(ActionKind.VOLUME, "raise", "Volume up"),
+            ActionSpec(ActionKind.VOLUME, "lower", "Volume down"),
+            ActionSpec(ActionKind.VOLUME, "mute", "Mute / unmute")
+        )
+        out += "Page turning" to listOf(
+            ActionSpec(ActionKind.SWIPE, "left", "Swipe left (next page)"),
+            ActionSpec(ActionKind.SWIPE, "right", "Swipe right (previous page)"),
+            ActionSpec(ActionKind.SCROLL, "forward", "Scroll forward"),
+            ActionSpec(ActionKind.SCROLL, "backward", "Scroll backward")
+        )
+        if (Device.hasViwoodsActions) out += "Viwoods AI" to Presets.viwoods.map { it.toSpec() }
+        return out
+    }
+
     fun spec(items: List<ActionSpec>): ActionSpec =
         ActionSpec(ActionKind.MENU, encode(items), "Menu: " + items.joinToString(", ") { it.describe() })
 }
diff --git a/app/src/main/java/dev/equwal/assistkey/model/ActionSpec.kt b/app/src/main/java/dev/equwal/assistkey/model/ActionSpec.kt
index 700c6da..23e5d1d 100644
--- a/app/src/main/java/dev/equwal/assistkey/model/ActionSpec.kt
+++ b/app/src/main/java/dev/equwal/assistkey/model/ActionSpec.kt
@@ -74,6 +74,8 @@ data class ActionSpec(
     private fun dimName(): String = when (payload) {
         "darker" -> "Extra dim darker"
         "brighter" -> "Extra dim brighter"
+        "system_up" -> "Brightness up"
+        "system_down" -> "Brightness down"
         else -> "Extra dim toggle"
     }
 
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/AccessibilityDisclosure.kt b/app/src/main/java/dev/equwal/assistkey/ui/AccessibilityDisclosure.kt
new file mode 100644
index 0000000..ae540b0
--- /dev/null
+++ b/app/src/main/java/dev/equwal/assistkey/ui/AccessibilityDisclosure.kt
@@ -0,0 +1,46 @@
+package dev.equwal.assistkey.ui
+
+import android.app.Activity
+import android.app.AlertDialog
+
+/**
+ * What the accessibility service does, said before the user is sent to switch
+ * it on.
+ *
+ * Google Play requires this of an app that uses the accessibility interface for
+ * a purpose other than assistive technology: in the app, in plain words, with a
+ * yes before anything else happens. It is one text in one place, so that every
+ * screen which leads to the switch shows the same words, and so that the words
+ * change when the service learns to do something new.
+ */
+object AccessibilityDisclosure {
+
+    const val TEXT =
+        "AssistKey uses the Android AccessibilityService API to remap the hardware " +
+            "keys of this device and to carry out the actions you bind to them.\n\n" +
+            "With the service on, AssistKey:\n\n" +
+            "- receives key presses, so that it can recognise taps, holds and " +
+            "combinations;\n\n" +
+            "- does the action you chose for you: Back, Home, Recents, a swipe, a " +
+            "scroll;\n\n" +
+            "- looks at the window in front to find its scrollable area, when you " +
+            "use the Scroll action;\n\n" +
+            "- puts recognised words into the text field that has the cursor, when " +
+            "you use Voice typing. It never writes into password fields;\n\n" +
+            "- notes the name of the app in front, in memory only, so that a key " +
+            "can take you back to it.\n\n" +
+            "It does not record what you type or what is on your screen. It collects " +
+            "nothing, keeps nothing and sends nothing. The app has no internet " +
+            "permission.\n\n" +
+            "Agree to go to the accessibility settings of Android."
+
+    fun show(a: Activity, onAgree: () -> Unit, onDecline: () -> Unit = {}) {
+        AlertDialog.Builder(a)
+            .setTitle("Accessibility service")
+            .setMessage(TEXT)
+            .setPositiveButton("Agree") { _, _ -> onAgree() }
+            .setNegativeButton("Not now") { _, _ -> onDecline() }
+            .setOnCancelListener { onDecline() }
+            .show()
+    }
+}
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/ActionPickerActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/ActionPickerActivity.kt
index 5656406..2a02bf1 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/ActionPickerActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/ActionPickerActivity.kt
@@ -49,9 +49,14 @@ class ActionPickerActivity : Activity() {
         if (!pickOnly) {
             col.note("Currently: " + Store.bindings(this).raw(trigger).describe())
             col.header("More than one")
-            col.row("Menu of actions", "This gesture opens a menu, and the menu holds as many actions as you want") {
+            col.row("Build a menu", "This gesture opens a menu, and the menu holds as many actions as you want") {
                 startActivityForResult(dev.equwal.assistkey.menu.MenuEditActivity.intent(this, trigger), MENU)
             }
+            dev.equwal.assistkey.menu.Menu.presets(this).forEach { (name, items) ->
+                col.row(name + " menu", items.joinToString(", ") { it.describe() }) {
+                    choose(dev.equwal.assistkey.menu.Menu.spec(items))
+                }
+            }
         }
 
         if (!pickOnly) basics(col)
@@ -59,7 +64,7 @@ class ActionPickerActivity : Activity() {
         navigation(col)
         if (dev.equwal.assistkey.shell.Shell.SUPPORTED) {
             col.header("Light")
-            listOf("toggle", "darker", "brighter").forEach { what ->
+            listOf("toggle", "darker", "brighter", "system_up", "system_down").forEach { what ->
                 val spec = ActionSpec(ActionKind.DIM, what, "")
                 col.row(spec.describe(), if (what == "toggle") "On is the lowest level. Off is your system brightness." else null) {
                     choose(spec)
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 f3fa1f1..8e4e80a 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt
@@ -36,6 +36,7 @@ class MainActivity : Activity() {
         super.onCreate(savedInstanceState)
         Channels.syncComponents(this)
         dev.equwal.assistkey.shell.Shell.connect(this)
+        PermissionsActivity.showOnce(this)
     }
 
     override fun onResume() {
@@ -97,12 +98,10 @@ class MainActivity : Activity() {
             restrictedSettingsHint(col)
         }
 
-        if (dev.equwal.assistkey.shell.Shell.SUPPORTED) {
-            col.row(
-                "Shell access: " + dev.equwal.assistkey.shell.Shell.describe(this),
-                "Optional. Unlocks the Power button, the navigation bar and system gestures"
-            ) { startActivity(Intent(this, ShellActivity::class.java)) }
+        col.row("Permissions", "Everything the app can be allowed to do, and a way to grant it all again") {
+            startActivity(Intent(this, PermissionsActivity::class.java))
         }
+
     }
 
     /**
@@ -131,26 +130,7 @@ class MainActivity : Activity() {
      */
     private fun claim(ch: Channel) {
         if (ch != Channel.ACCESSIBILITY) return claimNow(ch)
-        AlertDialog.Builder(this)
-            .setTitle("Accessibility service")
-            .setMessage(
-                "AssistKey uses Android's AccessibilityService API for one purpose: " +
-                    "remapping this device's hardware keys.\n\n" +
-                    "With the service switched on, AssistKey:\n\n" +
-                    "- receives presses of the AI key and the volume keys, so that it " +
-                    "can recognise taps, holds and combinations;\n\n" +
-                    "- performs the action you chose - Back, Home, Recents, a swipe, " +
-                    "a scroll - on your behalf;\n\n" +
-                    "- looks at the window in front only to find its scrollable area, " +
-                    "and only when you use the Scroll action.\n\n" +
-                    "It does not record what you type or what is on your screen. It " +
-                    "collects nothing, stores nothing and sends nothing: the app has no " +
-                    "internet permission.\n\n" +
-                    "Agree to continue to Android's accessibility settings."
-            )
-            .setPositiveButton("Agree") { _, _ -> claimNow(ch) }
-            .setNegativeButton("Not now", null)
-            .show()
+        AccessibilityDisclosure.show(this, onAgree = { claimNow(ch) })
     }
 
     private fun claimNow(ch: Channel) {
@@ -233,11 +213,6 @@ class MainActivity : Activity() {
         }
 
         col.header("Display and home")
-        if (dev.equwal.assistkey.shell.Shell.SUPPORTED) {
-            col.row("Extra-dim light", "Below the lowest the system slider allows") {
-                startActivity(Intent(this, DisplayActivity::class.java))
-            }
-        }
         col.row("Home screen", "A plain, fast launcher made for e-ink") {
             startActivity(Intent(this, dev.equwal.assistkey.home.HomeSettingsActivity::class.java))
         }
@@ -257,6 +232,20 @@ class MainActivity : Activity() {
             ) { startActivity(Intent(this, ViwoodsActivity::class.java)) }
         }
 
+        // For the few who have Shizuku or root. It is kept out of the way on
+        // purpose: most people never need it, and the app is complete without it.
+        if (dev.equwal.assistkey.shell.Shell.SUPPORTED) {
+            col.row(
+                "Shell access: " + dev.equwal.assistkey.shell.Shell.describe(this),
+                "For devices with Shizuku or root. More Power button gestures, and system switches"
+            ) { startActivity(Intent(this, ShellActivity::class.java)) }
+            if (dev.equwal.assistkey.shell.Shell.ready) {
+                col.row("Extra-dim light", "Below the lowest the system slider allows") {
+                    startActivity(Intent(this, DisplayActivity::class.java))
+                }
+            }
+        }
+
         col.row("Export and import", "Save your settings to a file, or share them") {
             startActivity(Intent(this, BackupActivity::class.java))
         }
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/PermissionsActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/PermissionsActivity.kt
new file mode 100644
index 0000000..452812f
--- /dev/null
+++ b/app/src/main/java/dev/equwal/assistkey/ui/PermissionsActivity.kt
@@ -0,0 +1,192 @@
+package dev.equwal.assistkey.ui
+
+import android.Manifest
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.provider.Settings
+import android.widget.LinearLayout
+import dev.equwal.assistkey.channel.Channel
+import dev.equwal.assistkey.channel.Channels
+import dev.equwal.assistkey.home.Apps
+import dev.equwal.assistkey.native.PowerNative
+import dev.equwal.assistkey.shell.Shell
+import dev.equwal.assistkey.ui.Ui.button
+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
+import dev.equwal.assistkey.voice.Dictation
+
+/**
+ * Every permission the app can use, in one place: what it is for, whether it is
+ * granted, and a way to grant it.
+ *
+ * "Set up what is missing" walks through the missing ones in order. Android
+ * grants each of these on a screen of its own, so the walk opens one screen,
+ * waits for the user to come back, and opens the next. It can be run again at
+ * any time, which is the point: a reinstall or a system update takes grants
+ * away without saying so.
+ *
+ * Nothing here is required. The app works with whatever has been granted.
+ */
+class PermissionsActivity : Activity() {
+
+    private class Item(
+        val title: String,
+        val why: String,
+        val granted: () -> Boolean,
+        val ask: () -> Unit
+    )
+
+    private var walking = false
+    private var asked = HashSet<String>()
+
+    private fun items(): List<Item> {
+        val list = ArrayList<Item>()
+        list += Item(
+            "Key remapping",
+            "The accessibility key filter. It sees the keys and carries out the actions. Everything else depends on it.",
+            { Channels.isSatisfied(this, Channel.ACCESSIBILITY) }
+        ) {
+            Channels.setEnabled(this, Channel.ACCESSIBILITY, true)
+            AccessibilityDisclosure.show(
+                this,
+                onAgree = { start(Channels.claimIntent(this, Channel.ACCESSIBILITY)) },
+                onDecline = { next() }
+            )
+        }
+        list += Item(
+            "Digital assistant",
+            "So that holding the Power button reaches this app.",
+            { Channels.isSatisfied(this, Channel.ASSISTANT) }
+        ) {
+            Channels.setEnabled(this, Channel.ASSISTANT, true)
+            start(Channels.claimIntent(this, Channel.ASSISTANT))
+        }
+        list += Item(
+            "Microphone",
+            "For Voice typing only. A speech app on this device does the listening.",
+            { Dictation.hasMicrophone(this) }
+        ) { requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), 1) }
+        list += Item(
+            "App usage data",
+            "For the recent-apps cards, to put apps in order of last use. Not needed with shell access.",
+            { Apps.hasUsageAccess(this) || Shell.ready }
+        ) { start(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)) }
+        return list
+    }
+
+    override fun onResume() {
+        super.onResume()
+        Shell.connect(this)
+        build()
+        if (walking) next()
+    }
+
+    override fun onRequestPermissionsResult(code: Int, permissions: Array<out String>, results: IntArray) {
+        super.onRequestPermissionsResult(code, permissions, results)
+        build()
+        if (walking) next()
+    }
+
+    private var lastStep = 0L
+
+    /**
+     * Opens the first missing grant that this walk has not asked for yet. Coming
+     * back from a permission dialog reports twice, as a result and as a resume,
+     * so a second call close behind the first is dropped.
+     */
+    private fun next() {
+        val now = android.os.SystemClock.uptimeMillis()
+        if (now - lastStep < 600) return
+        lastStep = now
+        val item = items().firstOrNull { !it.granted() && it.title !in asked }
+        if (item == null) {
+            walking = false
+            build()
+            return
+        }
+        asked.add(item.title)
+        item.ask()
+    }
+
+    private fun start(i: Intent) {
+        if (!Channels.safeStart(this, i)) next()
+    }
+
+    private fun build() {
+        val all = items()
+        val missing = all.count { !it.granted() }
+        val col = Ui.page(this)
+        col.title("Permissions")
+        col.note(
+            "What the app can be allowed to do, and why. Nothing here is required: " +
+                "the app works with whatever you grant."
+        )
+        if (missing == 0) {
+            col.note("Everything is granted.")
+        } else {
+            col.button("Set up what is missing (" + missing + ")") {
+                walking = true
+                asked = HashSet()
+                next()
+            }
+            col.note(
+                "Android grants each one on a screen of its own. Come back here " +
+                    "after each, and the next one opens."
+            )
+        }
+
+        col.header("One at a time")
+        all.forEach { item -> entry(col, item) }
+
+        if (Shell.SUPPORTED) {
+            col.header("For devices with Shizuku or root")
+            col.row(
+                "Shell access: " + Shell.describe(this),
+                "Optional. More Power button gestures, and system switches"
+            ) { startActivity(Intent(this, ShellActivity::class.java)) }
+        }
+
+        col.header("If a switch turns itself back off")
+        col.note(
+            "Android blocks some switches for apps that were installed from a file. " +
+                "Open App info, tap the three-dot menu, choose Allow restricted " +
+                "settings, then try again."
+        )
+        col.button("Open App info") { Channels.safeStart(this, Channels.appInfoIntent(this)) }
+
+        if (!PowerNative.canWriteSecure(this)) {
+            col.header("From a computer, once")
+            col.note(
+                "One permission cannot be granted on the device" +
+                    (if (Shell.SUPPORTED) " without shell access" else "") +
+                    ". It lets the app change the system's own Power button and Emergency SOS settings."
+            )
+            col.code(PowerNative.GRANT_COMMAND)
+        }
+    }
+
+    private fun entry(col: LinearLayout, item: Item) {
+        val ok = item.granted()
+        col.row((if (ok) "Granted: " else "Missing: ") + item.title, item.why) {
+            walking = false
+            item.ask()
+        }
+    }
+
+    companion object {
+        private const val PREFS = "assistkey_setup"
+        private const val K_SHOWN = "permissions_shown"
+
+        /** Opens this screen the first time the app runs, and never again by itself. */
+        fun showOnce(a: Activity) {
+            val p = a.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+            if (p.getBoolean(K_SHOWN, false)) return
+            p.edit().putBoolean(K_SHOWN, true).apply()
+            a.startActivity(Intent(a, PermissionsActivity::class.java))
+        }
+    }
+}
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 8a17205..82b7493 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/PowerActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/PowerActivity.kt
@@ -143,28 +143,9 @@ class PowerActivity : Activity() {
 
     private fun sideDoors(col: LinearLayout) {
         col.note(
-            if (Shell.SUPPORTED) {
-                "Android does not show the Power button to apps. With shell access " +
-                    "AssistKey can read it anyway and every gesture becomes available."
-            } else {
-                "Android does not show the Power button to apps. Two side doors are " +
-                    "open: press and hold, and double press."
-            }
+            "Android does not show the Power button to apps. Two side doors are " +
+                "open: press and hold, and double press."
         )
-        if (!Shell.SUPPORTED) {
-            // No shell access in this build: the side doors are everything.
-        } else if (Shell.ready) {
-            col.check("Let AssistKey handle the Power button", "Tap, double tap, hold and more", false) {
-                PowerControl.setWanted(this, it)
-                (ServiceHolder.service as? KeyFilterService)?.syncPower()
-                build()
-            }
-        } else {
-            col.row("Shell access: " + Shell.describe(this), "Unlocks tap, double tap, more taps and combinations") {
-                startActivity(Intent(this, ShellActivity::class.java))
-            }
-        }
-
         col.header("Hold - through the assistant door")
         door(col, Channel.ASSISTANT)
         bindRow(col, "Press and hold", Channel.POWER_HOLD)
@@ -190,6 +171,22 @@ class PowerActivity : Activity() {
         col.header("Wallet door")
         door(col, Channel.WALLET)
         col.note("Runs the double-press action from the wallet tile and lock-screen button.")
+
+        if (Shell.SUPPORTED) col.header("With Shizuku or root")
+        if (!Shell.SUPPORTED) {
+            // No shell access in this build: the side doors are everything.
+        } else if (Shell.ready) {
+            col.check("Let AssistKey handle the Power button", "Tap, double tap, hold and more", false) {
+                PowerControl.setWanted(this, it)
+                (ServiceHolder.service as? KeyFilterService)?.syncPower()
+                build()
+            }
+        } else {
+            col.row("Shell access: " + Shell.describe(this), "Unlocks tap, double tap, more taps and combinations") {
+                startActivity(Intent(this, ShellActivity::class.java))
+            }
+        }
+
     }
 
     /** One side door: its switch, what state it is in, and how to open it. */
diff --git a/app/src/test/java/dev/equwal/assistkey/display/ExtraDimStepTest.kt b/app/src/test/java/dev/equwal/assistkey/display/ExtraDimStepTest.kt
index 53fdba1..c79798b 100644
--- a/app/src/test/java/dev/equwal/assistkey/display/ExtraDimStepTest.kt
+++ b/app/src/test/java/dev/equwal/assistkey/display/ExtraDimStepTest.kt
@@ -32,6 +32,23 @@ class ExtraDimStepTest {
         levels.forEach { assertEquals(0, ExtraDim.toggled(it, levels)) }
     }
 
+    @Test fun `system brightness steps are fine when low, coarse when high, and stay in range`() {
+        assertEquals(10, ExtraDim.systemStep(5, up = true, floor = 5))
+        assertEquals(5, ExtraDim.systemStep(8, up = false, floor = 5))
+        assertEquals(250, ExtraDim.systemStep(200, up = true, floor = 5))
+        assertEquals(255, ExtraDim.systemStep(250, up = true, floor = 5))
+        assertEquals(5, ExtraDim.systemStep(5, up = false, floor = 5))
+    }
+
+    @Test fun `system brightness never leaves the range and never moves the wrong way`() {
+        (0..255).forEach { v ->
+            val down = ExtraDim.systemStep(v, up = false, floor = 5)
+            val up = ExtraDim.systemStep(v, up = true, floor = 5)
+            assert(down in 5..255 && up in 5..255) { "value $v gave $down and $up" }
+            assert(down <= maxOf(v, 5) && up >= v) { "value $v moved the wrong way" }
+        }
+    }
+
     @Test fun `a device with no levels stays off`() {
         assertEquals(0, ExtraDim.toggled(0, emptyList()))
         assertEquals(0, ExtraDim.darker(0, emptyList()))
diff --git a/app/src/test/java/dev/equwal/assistkey/model/ActionSpecNameTest.kt b/app/src/test/java/dev/equwal/assistkey/model/ActionSpecNameTest.kt
index cf334b0..de5fcd9 100644
--- a/app/src/test/java/dev/equwal/assistkey/model/ActionSpecNameTest.kt
+++ b/app/src/test/java/dev/equwal/assistkey/model/ActionSpecNameTest.kt
@@ -9,6 +9,8 @@ class ActionSpecNameTest {
         assertEquals("Extra dim toggle", ActionSpec(ActionKind.DIM, "toggle", "").describe())
         assertEquals("Extra dim darker", ActionSpec(ActionKind.DIM, "darker", "").describe())
         assertEquals("Extra dim brighter", ActionSpec(ActionKind.DIM, "brighter", "").describe())
+        assertEquals("Brightness up", ActionSpec(ActionKind.DIM, "system_up", "").describe())
+        assertEquals("Brightness down", ActionSpec(ActionKind.DIM, "system_down", "").describe())
     }
 
     // A binding made by 0.0.6 stored the old label. It must show the new name.
diff --git a/play/CHECKLIST.md b/play/CHECKLIST.md
index 9ae0d76..6c897c7 100644
--- a/play/CHECKLIST.md
+++ b/play/CHECKLIST.md
@@ -27,7 +27,7 @@ Play Console > **Create app**
 
 | Field | Value |
 |---|---|
-| App name | `AssistKey: Key Remapper` |
+| App name | `AssistKey: E-Ink Key Remap` |
 | Default language | English (United States) |
 | App or game | App |
 | Free or paid | **Free** (the licence is an in-app product; a paid app could not give testers a discount) |
@@ -90,6 +90,12 @@ to other currencies.
 `assistkey_beta_open` is never offered by the app. It is only a switch - see
 section 9.
 
+Price review, 2026-09-21: the market check advises **US$4.99**, not US$2.99.
+Reason: US$2.99 is the exact price of Button Mapper Pro, which has 5M+ installs,
+so a price match cannot win; the e-ink features have no paid competitor. At
+US$4.99 the margin is US$4.24 per sale, and US$1,000 a month needs 236 sales
+and not 394. The owner has not decided. A price is a Console edit, not a release.
+
 Pricing: Button Mapper Pro, the nearest comparable app, is US$2.99. AssistKey
 serves a far smaller audience but does more on the hardware it targets, so it
 matches that price rather than undercutting it. Prices are read from Play at
diff --git a/play/DECLARATIONS.md b/play/DECLARATIONS.md
index 0918ff3..4798a32 100644
--- a/play/DECLARATIONS.md
+++ b/play/DECLARATIONS.md
@@ -64,7 +64,7 @@ disabilities)?** No. The manifest does not set `isAccessibilityTool`.
 ```
 AssistKey is a hardware key remapper for the Viwoods AiPaper Reader, an e-ink reading device. Remapping hardware keys is the app's only function, and the AccessibilityService API is the only public Android API that can do it.
 
-The service is used for three things:
+The service is used for four things:
 
 1. Key event filtering (flagRequestFilterKeyEvents / onKeyEvent). The service receives presses of the device's AI key and volume keys, recognises the gesture the user configured - single or multiple taps, press-and-hold, or a combination of keys - and consumes the press so that the user's chosen action runs instead of the default one.
 
@@ -72,6 +72,8 @@ The service is used for three things:
 
 3. Voice typing, if the user binds that action: the service finds the text field that has input focus and inserts the recognised words at the cursor (ACTION_SET_TEXT, or ACTION_PASTE as the fallback). It never writes into password fields.
 
+4. Going back to the app that was in use, on Viwoods readers: the service reads the package name of the window in front from window-state events, keeps the latest one in memory only, and uses it to return from the maker's AI screen when the user presses the AI key there.
+
 Window content is retrieved only in cases 2 and 3: to locate a scrollable node at the moment the user presses a key bound to the Scroll action, and to read the one focused text field at the moment recognised words are inserted into it. No window content, text, or key event is stored, logged or transmitted. The app does not request the INTERNET permission and cannot transmit anything.
 
 Before the user is sent to the accessibility settings, the app shows a prominent in-app disclosure describing exactly this use, and proceeds only if the user taps Agree.
diff --git a/play/LISTING.md b/play/LISTING.md
index 9e6088a..40c8165 100644
--- a/play/LISTING.md
+++ b/play/LISTING.md
@@ -5,13 +5,20 @@ Paste into Play Console > Store presence > Main store listing.
 ## App name (30 max)
 
 ```
-AssistKey: Key Remapper
+AssistKey: E-Ink Key Remap
 ```
 
+26 characters. Keep "AssistKey" as the brand and put no device maker's name in
+the title. A maker's name at the front of a title ("Viwoods ...") reads as an
+official app: Play can suspend it as impersonation, and the maker can remove it
+with one trademark complaint. It also tells every Boox owner the app is not for
+them, and Boox is the larger market by a wide margin. Maker names belong in the
+long description, as plain compatibility facts, with the disclaimer below.
+
 ## Short description (80 max)
 
 ```
-Remap volume keys, the power button and more. Made for e-ink readers.
+Remap keys, page turn and voice type on E-Ink readers and any Android.
 ```
 
 ## Full description (4000 max)
@@ -46,6 +53,7 @@ ACTIONS
 • Open any app, or any screen inside an app
 • Send an intent or a broadcast, for automation apps
 • A menu of actions: one gesture opens a menu with as many actions as you want, so you have more actions than keys
+• Ready-made menus for navigation, system, sound and media, and page turning
 • Do nothing - disable a key you keep pressing by accident
 • Export and import your setup as a file, to move it to another device or share it
 
@@ -75,6 +83,11 @@ GOOD TO KNOW
 • 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.
 
+COMPATIBILITY
+Works on Android 12 and later. Built and tested on the Viwoods AiPaper Reader; key remapping, page turning, voice typing and the home screen work on Boox, Bigme, Meebook and ordinary phones and tablets too. Search terms people use for this: button mapper, key remapper, remap buttons, volume key page turn, double tap, long press, key combination.
+
+AssistKey is not affiliated with, or endorsed by, Viwoods, Onyx Boox, Bigme or Meebook.
+
 PRICE
 
 Free to install and try. A one-time purchase unlocks it permanently - no subscription. Your licence follows your Google account to every device you own.