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 1d617cdedebbfc8047d37df2e912df5f7b120887
truex <truex@equwal.com>
2026-09-21 01:09:23 -0700

Add guided button setup and a new main screen

The main screen starts with Set up a button. The user taps a button in a
drawing of the device, chooses how to press it, and chooses the action.
Route works out which grants the binding needs, and the app asks for
only those. The app does not ask the user to press a key to find it.

The earlier screens stay under Advanced > Full control. Guided setup and
full control edit the same bindings.

All screens use the new Ui page, tile, chip and primary button parts.
Version 0.0.8-alpha, version code 8.

 CHANGELOG.md                                       |  21 +
 README.md                                          |  10 +
 app/src/main/AndroidManifest.xml                   |   7 +
 .../equwal/assistkey/home/HomeSettingsActivity.kt  |  19 +-
 .../dev/equwal/assistkey/menu/MenuEditActivity.kt  |  12 +-
 .../java/dev/equwal/assistkey/setup/DeviceView.kt  | 108 +++++
 .../equwal/assistkey/setup/GuidedSetupActivity.kt  | 274 +++++++++++++
 .../main/java/dev/equwal/assistkey/setup/Route.kt  |  80 ++++
 .../equwal/assistkey/ui/ActionPickerActivity.kt    |   6 +-
 .../dev/equwal/assistkey/ui/AdvancedActivity.kt    | 122 ++++++
 .../java/dev/equwal/assistkey/ui/BackupActivity.kt |  29 +-
 .../java/dev/equwal/assistkey/ui/ChordActivity.kt  |  33 +-
 .../dev/equwal/assistkey/ui/DisplayActivity.kt     |  44 +-
 .../dev/equwal/assistkey/ui/HomeHubActivity.kt     |  57 +++
 .../dev/equwal/assistkey/ui/KeyTesterActivity.kt   |  38 +-
 .../java/dev/equwal/assistkey/ui/KeysActivity.kt   |  95 +++++
 .../dev/equwal/assistkey/ui/LicenseActivity.kt     |   9 +-
 .../java/dev/equwal/assistkey/ui/MainActivity.kt   | 347 +++++++---------
 .../dev/equwal/assistkey/ui/NavigationActivity.kt  |  45 ++-
 .../dev/equwal/assistkey/ui/PermissionsActivity.kt |  70 ++--
 .../java/dev/equwal/assistkey/ui/PowerActivity.kt  |  65 +--
 .../java/dev/equwal/assistkey/ui/ReportActivity.kt |  12 +-
 .../java/dev/equwal/assistkey/ui/SetupActivity.kt  | 108 +++++
 .../java/dev/equwal/assistkey/ui/ShellActivity.kt  |  44 +-
 .../main/java/dev/equwal/assistkey/ui/Summary.kt   |  93 +++++
 .../java/dev/equwal/assistkey/ui/TimingActivity.kt |   6 +-
 .../dev/equwal/assistkey/ui/TriggerListActivity.kt |  28 +-
 app/src/main/java/dev/equwal/assistkey/ui/Ui.kt    | 444 ++++++++++++++++++---
 .../dev/equwal/assistkey/ui/ViwoodsActivity.kt     |  11 +-
 .../java/dev/equwal/assistkey/ui/VoiceActivity.kt  |  59 +--
 .../java/dev/equwal/assistkey/setup/RouteTest.kt   |  67 ++++
 .../java/dev/equwal/assistkey/ui/SummaryTest.kt    | 108 +++++
 gradle.properties                                  |   4 +-
 play/LISTING.md                                    |   2 +
 34 files changed, 1977 insertions(+), 500 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b1e82ad..0aabc6c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,26 @@
 # Changelog
 
+## 0.0.8-alpha - 2026-09-21
+
+Version code 8.
+
+- **Set up a button.** The main screen starts with one large button. The user
+  taps a button in a drawing of the device, chooses tap, double tap or hold,
+  and chooses what the button does. The app then works out what that needs
+  (key filter, assistant role, camera role, microphone) and asks only for
+  that, by what it does. The app never asks the user to press a key to find
+  it. A button that is not in the drawing is chosen from a list of names.
+- **New main screen.** A top bar with the licence state, "Your buttons" with
+  the bindings in use, and five tiles: Navigation, Voice typing, Home and
+  recents, Setup, Advanced. It fits on one screen with no scroll.
+- **Advanced > Full control.** The screens from before (every key and gesture,
+  the Power button, two-key combinations) are all still there. Guided setup
+  and full control edit the same bindings.
+- All screens use one look: title bar with a back arrow, state chips, one
+  filled main button for each screen, less text, details behind "More".
+- Tests: `RouteTest` (what a wish needs, which gestures a button can have) and
+  `SummaryTest` (the one-line states on the main screen).
+
 ## 0.0.7-alpha - 2026-09-21
 
 Version code 7.
diff --git a/README.md b/README.md
index 38c5403..9febfa9 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,16 @@ Sold on Google Play as a free download with a one-time licence. Publishing,
 products, pricing and how the beta is run and ended are in
 [play/CHECKLIST.md](play/CHECKLIST.md). The app holds no `INTERNET` permission.
 
+## Set up a button
+
+The main screen starts with **Set up a button**. Tap the button in the drawing
+of the device. Choose tap, double tap or hold. Choose what it does. The app
+finds what Android must allow for that, and asks for only that. The app does
+not ask you to press a key to find it.
+
+The same bindings are under **Advanced > Full control**, with every key, every
+gesture, the Power button and two-key combinations.
+
 ## Two builds
 
 One app, one id, one signature, two flavours:
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 34adda6..5869c67 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -82,6 +82,13 @@
         <activity android:name=".ui.VoiceActivity" android:exported="false" />
         <activity android:name=".ui.BackupActivity" android:exported="false" />
         <activity android:name=".ui.PermissionsActivity" android:exported="false" />
+        <!-- The hub's sub-screens. -->
+        <activity android:name=".ui.KeysActivity" android:exported="false" />
+        <activity android:name=".ui.SetupActivity" android:exported="false" />
+        <activity android:name=".ui.HomeHubActivity" android:exported="false" />
+        <activity android:name=".ui.AdvancedActivity" android:exported="false" />
+        <!-- The guided flow the hub opens first. -->
+        <activity android:name=".setup.GuidedSetupActivity" 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/home/HomeSettingsActivity.kt b/app/src/main/java/dev/equwal/assistkey/home/HomeSettingsActivity.kt
index 6d35c99..34c0532 100644
--- a/app/src/main/java/dev/equwal/assistkey/home/HomeSettingsActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/home/HomeSettingsActivity.kt
@@ -11,8 +11,8 @@ import dev.equwal.assistkey.ui.Ui.button
 import dev.equwal.assistkey.ui.Ui.check
 import dev.equwal.assistkey.ui.Ui.header
 import dev.equwal.assistkey.ui.Ui.note
+import dev.equwal.assistkey.ui.Ui.primaryButton
 import dev.equwal.assistkey.ui.Ui.row
-import dev.equwal.assistkey.ui.Ui.title
 
 /**
  * Switching the AssistKey home screen on, and tuning it.
@@ -40,13 +40,8 @@ class HomeSettingsActivity : Activity() {
     }
 
     private fun build() {
-        val col = Ui.page(this)
-        col.title("Home screen")
-        col.note(
-            "A home screen with nothing on it: a clock, the few apps you choose, and " +
-                "a line to type in. Apps stay out of sight until you type, and when " +
-                "one match is left it opens. No icons, no grid, nothing to redraw."
-        )
+        val col = Ui.page(this, "Home screen")
+        col.note("A clock, the few apps you choose, and a line to type in.")
 
         col.check("Offer AssistKey as a home screen", null, enabled()) { on ->
             packageManager.setComponentEnabledSetting(
@@ -60,11 +55,13 @@ class HomeSettingsActivity : Activity() {
         if (!enabled()) return
 
         col.row(
-            "Status: " + if (isDefault()) "This is your home screen" else "Not your home screen yet",
-            null, enabled = false
+            "Home screen",
+            if (isDefault()) "This is your home screen" else "Not your home screen yet",
+            enabled = false,
+            state = if (isDefault()) "In use" else "Ready"
         )
         if (!isDefault()) {
-            col.button("Make it the home screen") {
+            col.primaryButton("Make it the home screen") {
                 val rm = getSystemService(RoleManager::class.java)
                 val i = if (rm != null && rm.isRoleAvailable(RoleManager.ROLE_HOME)) {
                     rm.createRequestRoleIntent(RoleManager.ROLE_HOME)
diff --git a/app/src/main/java/dev/equwal/assistkey/menu/MenuEditActivity.kt b/app/src/main/java/dev/equwal/assistkey/menu/MenuEditActivity.kt
index f22ef1c..370ee2b 100644
--- a/app/src/main/java/dev/equwal/assistkey/menu/MenuEditActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/menu/MenuEditActivity.kt
@@ -13,8 +13,8 @@ import dev.equwal.assistkey.ui.Ui
 import dev.equwal.assistkey.ui.Ui.button
 import dev.equwal.assistkey.ui.Ui.header
 import dev.equwal.assistkey.ui.Ui.note
+import dev.equwal.assistkey.ui.Ui.primaryButton
 import dev.equwal.assistkey.ui.Ui.row
-import dev.equwal.assistkey.ui.Ui.title
 import org.json.JSONObject
 
 /** Builds the menu that one gesture opens: add actions, order them, save. */
@@ -38,12 +38,8 @@ class MenuEditActivity : Activity() {
     }
 
     private fun build() {
-        val col = Ui.page(this)
-        col.title("Menu for " + trigger.label())
-        col.note(
-            "One gesture opens a menu, and the menu holds as many actions as you " +
-                "want. That gives you more actions than you have keys."
-        )
+        val col = Ui.page(this, "Menu for " + trigger.label())
+        col.note("One gesture opens a menu, so you get more actions than you have keys.")
 
         col.header(if (items.isEmpty()) "No actions yet" else "Actions, in menu order")
         items.forEachIndexed { i, spec ->
@@ -64,7 +60,7 @@ class MenuEditActivity : Activity() {
         }
 
         col.header("Finish")
-        col.button(if (items.isEmpty()) "Remove the menu" else "Save the menu") {
+        col.primaryButton(if (items.isEmpty()) "Remove the menu" else "Save the menu") {
             Store.bind(this, trigger, if (items.isEmpty()) ActionSpec.PASS else Menu.spec(items))
             setResult(RESULT_OK)
             finish()
diff --git a/app/src/main/java/dev/equwal/assistkey/setup/DeviceView.kt b/app/src/main/java/dev/equwal/assistkey/setup/DeviceView.kt
new file mode 100644
index 0000000..75bab24
--- /dev/null
+++ b/app/src/main/java/dev/equwal/assistkey/setup/DeviceView.kt
@@ -0,0 +1,108 @@
+package dev.equwal.assistkey.setup
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.RectF
+import android.graphics.Typeface
+import android.view.GestureDetector
+import android.view.MotionEvent
+import android.view.View
+import dev.equwal.assistkey.model.HwKey
+import kotlin.math.abs
+
+/**
+ * A drawing of the device with its buttons along one edge.
+ *
+ * It is the way to choose a button: tap a button in the drawing, or swipe up and
+ * down through them. The drawing is a diagram, not a photograph: it shows which
+ * buttons there are, in a fixed order, and does not claim where they sit on a
+ * given device. The chosen button is filled black and its name is bold.
+ *
+ * Drawn for e-ink: black lines on white, no shading, no animation.
+ */
+class DeviceView(context: Context) : View(context) {
+
+    var keys: List<HwKey> = emptyList()
+        set(value) { field = value; invalidate() }
+
+    var selected: HwKey? = null
+        set(value) { field = value; invalidate() }
+
+    var onSelect: ((HwKey) -> Unit)? = null
+
+    private val d = resources.displayMetrics.density
+    private val line = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+        style = Paint.Style.STROKE
+        strokeWidth = 2.5f * d
+        color = Color.BLACK
+    }
+    private val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL; color = Color.BLACK }
+    private val paper = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL; color = Color.WHITE }
+    private val label = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.BLACK; textSize = 17f * d }
+    private val rows = ArrayList<Pair<HwKey, RectF>>()
+
+    private val gestures = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
+        override fun onDown(e: MotionEvent): Boolean = true
+
+        override fun onSingleTapUp(e: MotionEvent): Boolean {
+            rows.firstOrNull { it.second.contains(e.x, e.y) }?.let { choose(it.first) }
+            return true
+        }
+
+        /** A swipe up or down goes to the next or the previous button. */
+        override fun onFling(e1: MotionEvent?, e2: MotionEvent, vx: Float, vy: Float): Boolean {
+            if (keys.isEmpty() || abs(vy) < abs(vx)) return false
+            val i = keys.indexOf(selected)
+            val next = if (vy < 0) i + 1 else i - 1
+            choose(keys[next.coerceIn(0, keys.size - 1)])
+            return true
+        }
+    })
+
+    private fun choose(key: HwKey) {
+        selected = key
+        onSelect?.invoke(key)
+    }
+
+    override fun onTouchEvent(event: MotionEvent): Boolean {
+        if (event.actionMasked == MotionEvent.ACTION_UP) performClick()
+        return gestures.onTouchEvent(event)
+    }
+
+    override fun performClick(): Boolean = super.performClick()
+
+    override fun onMeasure(widthSpec: Int, heightSpec: Int) {
+        val w = MeasureSpec.getSize(widthSpec)
+        setMeasuredDimension(w, (300 * d).toInt())
+    }
+
+    override fun onDraw(canvas: Canvas) {
+        val w = width.toFloat()
+        val h = height.toFloat()
+        val body = RectF(w * 0.10f, 6 * d, w * 0.46f, h - 6 * d)
+        val corner = 18 * d
+        canvas.drawRoundRect(body, corner, corner, paper)
+        canvas.drawRoundRect(body, corner, corner, line)
+        val inset = 12 * d
+        canvas.drawRect(body.left + inset, body.top + inset * 1.6f, body.right - inset, body.bottom - inset * 1.6f, line)
+
+        rows.clear()
+        if (keys.isEmpty()) return
+        val slot = (body.height() - 2 * corner) / keys.size
+        keys.forEachIndexed { i, key ->
+            val cy = body.top + corner + slot * (i + 0.5f)
+            val half = minOf(slot * 0.32f, 20 * d)
+            val nub = RectF(body.right - 1 * d, cy - half, body.right + 12 * d, cy + half)
+            val chosen = key == selected
+            canvas.drawRoundRect(nub, 4 * d, 4 * d, if (chosen) fill else paper)
+            canvas.drawRoundRect(nub, 4 * d, 4 * d, line)
+            // A thin leader from the button to its name.
+            canvas.drawLine(nub.right, cy, w * 0.56f, cy, line)
+            label.typeface = if (chosen) Typeface.DEFAULT_BOLD else Typeface.DEFAULT
+            canvas.drawText(key.label, w * 0.58f, cy + label.textSize * 0.35f, label)
+            rows.add(key to RectF(body.right - 8 * d, cy - slot / 2, w, cy + slot / 2))
+        }
+    }
+}
diff --git a/app/src/main/java/dev/equwal/assistkey/setup/GuidedSetupActivity.kt b/app/src/main/java/dev/equwal/assistkey/setup/GuidedSetupActivity.kt
new file mode 100644
index 0000000..8ca6789
--- /dev/null
+++ b/app/src/main/java/dev/equwal/assistkey/setup/GuidedSetupActivity.kt
@@ -0,0 +1,274 @@
+package dev.equwal.assistkey.setup
+
+import android.Manifest
+import android.app.Activity
+import android.content.Intent
+import android.os.Bundle
+import android.widget.LinearLayout
+import android.widget.Toast
+import dev.equwal.assistkey.channel.Channel
+import dev.equwal.assistkey.channel.Channels
+import dev.equwal.assistkey.device.Device
+import dev.equwal.assistkey.engine.KeyFilterService
+import dev.equwal.assistkey.menu.MenuEditActivity
+import dev.equwal.assistkey.model.ActionKind
+import dev.equwal.assistkey.model.ActionSpec
+import dev.equwal.assistkey.model.GestureType
+import dev.equwal.assistkey.model.GlobalAction
+import dev.equwal.assistkey.model.HwKey
+import dev.equwal.assistkey.model.Trigger
+import dev.equwal.assistkey.route.ActionRouter
+import dev.equwal.assistkey.route.ServiceHolder
+import dev.equwal.assistkey.setup.Route.Need
+import dev.equwal.assistkey.shell.PowerControl
+import dev.equwal.assistkey.shell.Shell
+import dev.equwal.assistkey.store.Store
+import dev.equwal.assistkey.ui.AccessibilityDisclosure
+import dev.equwal.assistkey.ui.ActionPickerActivity
+import dev.equwal.assistkey.ui.Ui
+import dev.equwal.assistkey.ui.Ui.button
+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.primaryButton
+import dev.equwal.assistkey.voice.Dictation
+
+/**
+ * Set up one button by saying what you want, in four short steps.
+ *
+ *  1. Which button: tap it in the drawing.
+ *  2. How you press it: tap, double tap, hold.
+ *  3. What it does.
+ *  4. Allow what that needs - and only that.
+ *
+ * The user never chooses a service, a role or a channel. [Route] works out
+ * which of them the wish needs, and step 4 asks for those by what they do.
+ * Everything here ends in an ordinary binding, so the full-control screens
+ * show and edit the same thing.
+ */
+class GuidedSetupActivity : Activity() {
+
+    private enum class Step { BUTTON, PRESS, ACTION, ALLOW, DONE }
+
+    private var step = Step.BUTTON
+    private var key: HwKey? = null
+    private var trigger: Trigger? = null
+
+    private val env: Route.Env
+        get() = Route.Env(powerIsDirect = Shell.ready && PowerControl.wanted(this))
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        savedInstanceState?.let { s ->
+            step = Step.valueOf(s.getString("step", Step.BUTTON.name))
+            key = s.getString("key")?.let(HwKey::fromToken)
+            trigger = s.getString("trigger")?.let(Trigger::parse)
+        }
+    }
+
+    override fun onSaveInstanceState(out: Bundle) {
+        super.onSaveInstanceState(out)
+        out.putString("step", step.name)
+        key?.let { out.putString("key", it.token) }
+        trigger?.let { out.putString("trigger", it.id) }
+    }
+
+    override fun onResume() {
+        super.onResume()
+        Shell.connect(this)
+        // Back from the action list or the menu editor: a binding now exists, or not.
+        if (step == Step.ACTION && trigger?.let { Store.bindings(this).isBound(it) } == true) step = Step.ALLOW
+        build()
+    }
+
+    @Deprecated("Deprecated in Java")
+    override fun onBackPressed() {
+        when (step) {
+            Step.BUTTON, Step.DONE -> finish()
+            else -> { step = Step.entries[step.ordinal - 1]; build() }
+        }
+    }
+
+    private fun go(next: Step) {
+        step = next
+        build()
+    }
+
+    private fun build() {
+        val heading = when (step) {
+            Step.BUTTON -> "Which button?"
+            Step.PRESS -> key?.label ?: "Which button?"
+            Step.ACTION -> trigger?.label() ?: "What should it do?"
+            Step.ALLOW -> "Almost there"
+            Step.DONE -> "Done"
+        }
+        val col = Ui.page(this, heading)
+        when (step) {
+            Step.BUTTON -> whichButton(col)
+            Step.PRESS -> press(col)
+            Step.ACTION -> action(col)
+            Step.ALLOW -> allow(col)
+            Step.DONE -> done(col)
+        }
+    }
+
+    // ---- 1. which button ------------------------------------------------------------------
+
+    private fun whichButton(col: LinearLayout) {
+        col.note("Tap the button in the drawing.")
+        val view = DeviceView(this).apply {
+            keys = listOf(HwKey.POWER) + Device.keys(this@GuidedSetupActivity)
+            selected = key
+            onSelect = { key = it; build() }
+        }
+        col.addView(view)
+        // A device can have buttons the drawing does not know. The user names the button.
+        val others = HwKey.interceptable.filterNot { it in view.keys }
+        if (others.isNotEmpty()) {
+            col.row("My button is not in the drawing", key?.takeIf { it in others }?.label) {
+                Ui.pick(this, "Which button?", others.map { it.label }) { i -> key = others[i]; build() }
+            }
+        }
+        key?.let { k -> col.primaryButton("Next: " + k.label) { go(Step.PRESS) } }
+    }
+
+    // ---- 2. how it is pressed ---------------------------------------------------------------
+
+    private fun press(col: LinearLayout) {
+        val k = key ?: return go(Step.BUTTON)
+        col.header("How do you press it?")
+        val b = Store.bindings(this)
+        val offered = Route.gestures(setOf(k), env)
+        offered.forEach { t ->
+            col.row(gestureName(t), b.raw(t).describe().takeIf { b.isBound(t) }?.let { "Now: $it" }) {
+                trigger = t
+                go(Step.ACTION)
+            }
+        }
+        if (offered.size < 3) {
+            col.note("Android does not show a single tap of Power to apps. Hold and double press work.")
+        }
+        col.note("More ways to press, and two buttons together, are under Advanced > Full control.")
+    }
+
+    private fun gestureName(t: Trigger): String = when {
+        t.type == GestureType.HOLD -> "Press and hold"
+        t.count == 2 -> if (HwKey.POWER in t.keys && !env.powerIsDirect) "Double press" else "Double tap"
+        else -> "Tap"
+    }
+
+    // ---- 3. what it does ------------------------------------------------------------------------
+
+    private fun action(col: LinearLayout) {
+        val t = trigger ?: return go(Step.PRESS)
+        col.header("What should it do?")
+
+        fun global(a: GlobalAction) = ActionSpec(ActionKind.GLOBAL, a.name, a.label)
+        val common = listOf(
+            global(GlobalAction.BACK),
+            global(GlobalAction.HOME_CLOSE_IME),
+            ActionSpec(ActionKind.LAUNCH_COMPONENT, packageName + "/dev.equwal.assistkey.home.RecentsActivity", "Recent apps"),
+            ActionSpec(ActionKind.SWIPE, "left", "Next page"),
+            ActionSpec(ActionKind.SWIPE, "right", "Previous page"),
+            ActionSpec(ActionKind.VOICE, "", "Voice typing"),
+            ActionSpec(ActionKind.NONE, "", "Nothing - switch the button off")
+        )
+        common.forEach { spec ->
+            col.row(spec.describe(), null) {
+                Store.bind(this, t, spec)
+                go(Step.ALLOW)
+            }
+        }
+        col.row("A menu of actions", "One press, many choices") { startActivity(MenuEditActivity.intent(this, t)) }
+        col.row("Open an app, or something else", "The full list of actions") {
+            startActivity(ActionPickerActivity.intent(this, t))
+        }
+    }
+
+    // ---- 4. allow what it needs --------------------------------------------------------------------
+
+    private fun satisfied(n: Need): Boolean = when (n) {
+        Need.KEY_FILTER -> Channels.isSatisfied(this, Channel.ACCESSIBILITY)
+        Need.ASSISTANT -> Channels.isSatisfied(this, Channel.ASSISTANT)
+        Need.CAMERA -> Channels.isSatisfied(this, Channel.CAMERA)
+    }
+
+    private fun ask(n: Need) {
+        when (n) {
+            Need.KEY_FILTER -> {
+                Channels.setEnabled(this, Channel.ACCESSIBILITY, true)
+                AccessibilityDisclosure.show(this, onAgree = {
+                    Channels.safeStart(this, Channels.claimIntent(this, Channel.ACCESSIBILITY))
+                })
+            }
+            Need.ASSISTANT -> {
+                Channels.setEnabled(this, Channel.ASSISTANT, true)
+                Channels.safeStart(this, Channels.claimIntent(this, Channel.ASSISTANT))
+            }
+            Need.CAMERA -> {
+                Channels.setEnabled(this, Channel.CAMERA, true)
+                Toast.makeText(this, "Choose Camera app, then pick AssistKey", Toast.LENGTH_LONG).show()
+                Channels.safeStart(this, Channels.claimIntent(this, Channel.CAMERA))
+            }
+        }
+    }
+
+    private fun allow(col: LinearLayout) {
+        val t = trigger ?: return go(Step.PRESS)
+        val spec = Store.bindings(this)[t] ?: return go(Step.ACTION)
+        val needsFilter = ActionRouter.requiresAccessibility(spec) || spec.kind == ActionKind.MENU
+        val plan = Route.plan(t, needsFilter, env)
+
+        // The switches that go with the route, set without a question.
+        if (HwKey.POWER in t.keys && env.powerIsDirect) (ServiceHolder.service as? KeyFilterService)?.syncPower()
+        Channels.setEnabled(this, Channel.ACCESSIBILITY, true)
+
+        val missing = plan.needs.filterNot(::satisfied)
+        val microphone = spec.kind == ActionKind.VOICE && !Dictation.hasMicrophone(this)
+        if (plan.possible && missing.isEmpty() && !microphone) return go(Step.DONE)
+
+        plan.blocked?.let {
+            col.note(it)
+            col.button("Choose another way to press") { go(Step.PRESS) }
+            return
+        }
+        col.note(t.label() + " will do: " + spec.describe() + ". Android asks you to allow this first:")
+        missing.forEach { n -> col.row(n.title, n.why) { ask(n) } }
+        if (microphone) {
+            col.row("Allow the microphone", "For Voice typing. A speech app on this device does the listening.") {
+                requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), 1)
+            }
+        }
+        col.note("Tap each one. When you come back here, it is ticked off.")
+        if (Need.KEY_FILTER in missing) {
+            col.note("If the switch turns itself back off: open App info, the three-dot menu, Allow restricted settings.")
+            col.button("Open App info") { Channels.safeStart(this, Channels.appInfoIntent(this)) }
+        }
+    }
+
+    override fun onRequestPermissionsResult(code: Int, permissions: Array<out String>, results: IntArray) {
+        super.onRequestPermissionsResult(code, permissions, results)
+        build()
+    }
+
+    // ---- done -------------------------------------------------------------------------------------------
+
+    private fun done(col: LinearLayout) {
+        val t = trigger
+        val spec = t?.let { Store.bindings(this)[it] }
+        if (t != null && spec != null) col.note(t.label() + " now does: " + spec.describe() + ". Try it.")
+        if (spec?.kind == ActionKind.VOICE && Dictation.engines(this).none(Dictation::isOnDevice)) {
+            col.note("Voice typing works best with a speech app that listens on the device. See Voice typing on the main screen.")
+        }
+        col.button("Set up another button") {
+            key = null
+            trigger = null
+            go(Step.BUTTON)
+        }
+        col.primaryButton("Finish") { finish() }
+    }
+
+    companion object {
+        fun intent(a: Activity): Intent = Intent(a, GuidedSetupActivity::class.java)
+    }
+}
diff --git a/app/src/main/java/dev/equwal/assistkey/setup/Route.kt b/app/src/main/java/dev/equwal/assistkey/setup/Route.kt
new file mode 100644
index 0000000..442399d
--- /dev/null
+++ b/app/src/main/java/dev/equwal/assistkey/setup/Route.kt
@@ -0,0 +1,80 @@
+package dev.equwal.assistkey.setup
+
+import dev.equwal.assistkey.model.GestureType
+import dev.equwal.assistkey.model.HwKey
+import dev.equwal.assistkey.model.Trigger
+
+/**
+ * How a gesture can be made to work, and what that needs from the user.
+ *
+ * The user says what he wants: this button, pressed this way, does that. This
+ * object turns the wish into the list of things that must be allowed for it,
+ * so that no screen has to talk about services, roles or channels first.
+ *
+ * It has no Android types, so a test can run it with no device.
+ */
+object Route {
+
+    /** One thing the user must allow. The order of the entries is the order to ask in. */
+    enum class Need(val title: String, val why: String) {
+        KEY_FILTER(
+            "Let AssistKey see the buttons",
+            "Android calls this an accessibility service. AssistKey uses it to see button presses and to do the action."
+        ),
+        ASSISTANT(
+            "Make AssistKey the assistant app",
+            "Android sends a held Power button to the assistant app. That is how the press reaches AssistKey."
+        ),
+        CAMERA(
+            "Make AssistKey the camera app",
+            "Android sends a double press of Power to the camera app. That is how the press reaches AssistKey."
+        )
+    }
+
+    /** What is true of the device and the build at this moment. */
+    data class Env(
+        /** The app reads the Power button directly (shell access is on and in use). */
+        val powerIsDirect: Boolean
+    )
+
+    data class Plan(
+        val needs: List<Need>,
+        /** Null when the gesture can work. Otherwise the reason, in words for the user. */
+        val blocked: String? = null
+    ) {
+        val possible: Boolean get() = blocked == null
+    }
+
+    /**
+     * [actionNeedsFilter] is true for an action that the key filter carries out:
+     * Back, Home, a swipe, a scroll, voice typing.
+     */
+    fun plan(trigger: Trigger, actionNeedsFilter: Boolean, env: Env): Plan {
+        val power = HwKey.POWER in trigger.keys
+        if (!power || env.powerIsDirect) return Plan(listOf(Need.KEY_FILTER))
+
+        val alone = trigger.keys.size == 1
+        val door = when {
+            alone && trigger.type == GestureType.HOLD -> Need.ASSISTANT
+            alone && trigger.type == GestureType.TAP && trigger.count == 2 -> Need.CAMERA
+            // Power held, then one other button: the hold opens the assistant door.
+            !alone && trigger.keys.size == 2 && trigger.type == GestureType.TAP && trigger.count == 1 -> Need.ASSISTANT
+            else -> return Plan(
+                emptyList(),
+                "Android does not show this Power press to apps. Hold and double press work."
+            )
+        }
+        val second = !alone
+        return Plan(if (actionNeedsFilter || second) listOf(Need.KEY_FILTER, door) else listOf(door))
+    }
+
+    /** The ways to press [keys] that can work, for the choice the user is shown. */
+    fun gestures(keys: Set<HwKey>, env: Env): List<Trigger> {
+        val all = listOf(
+            Trigger(keys, GestureType.TAP, 1),
+            Trigger(keys, GestureType.TAP, 2),
+            Trigger(keys, GestureType.HOLD)
+        )
+        return all.filter { plan(it, false, env).possible }
+    }
+}
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 2a02bf1..05a11cc 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/ActionPickerActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/ActionPickerActivity.kt
@@ -14,7 +14,6 @@ import dev.equwal.assistkey.store.Store
 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
 
 /**
  * Everything a gesture can be bound to, in one list.
@@ -44,10 +43,9 @@ class ActionPickerActivity : Activity() {
     }
 
     private fun build() {
-        val col = Ui.page(this)
-        col.title(if (pickOnly) "Add to the menu" else trigger.label())
+        val col = Ui.page(this, if (pickOnly) "Add to the menu" else trigger.label())
         if (!pickOnly) {
-            col.note("Currently: " + Store.bindings(this).raw(trigger).describe())
+            col.row("Now", Store.bindings(this).raw(trigger).describe(), enabled = false)
             col.header("More than one")
             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)
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/AdvancedActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/AdvancedActivity.kt
new file mode 100644
index 0000000..808d774
--- /dev/null
+++ b/app/src/main/java/dev/equwal/assistkey/ui/AdvancedActivity.kt
@@ -0,0 +1,122 @@
+package dev.equwal.assistkey.ui
+
+import android.app.Activity
+import android.content.Intent
+import android.widget.LinearLayout
+import dev.equwal.assistkey.device.Device
+import dev.equwal.assistkey.model.HwKey
+import dev.equwal.assistkey.native.ViwoodsBridge
+import dev.equwal.assistkey.shell.Shell
+import dev.equwal.assistkey.store.Store
+import dev.equwal.assistkey.ui.Ui.header
+import dev.equwal.assistkey.ui.Ui.row
+
+/**
+ * Everything that is not part of setting up one button: the key-at-a-time way
+ * of working, the tools, and the settings file.
+ *
+ * Shell access lives at the bottom, on purpose. Most people never need it and
+ * the app is complete without it. It is not in the `play` build at all.
+ */
+class AdvancedActivity : Activity() {
+
+    private val redraw: () -> Unit = { if (!isFinishing) build() }
+
+    override fun onResume() {
+        super.onResume()
+        Shell.onChange(redraw)
+        Shell.connect(this)
+        build()
+    }
+
+    override fun onPause() {
+        super.onPause()
+        Shell.removeOnChange(redraw)
+    }
+
+    private fun build() {
+        val col = Ui.page(this, "Advanced")
+        fullControl(col)
+        tools(col)
+        settingsFile(col)
+        shell(col)
+    }
+
+    /** The hardware view: a key at a time, the Power button, combinations. */
+    private fun fullControl(col: LinearLayout) {
+        col.header("Full control")
+        col.row(
+            "Keys",
+            Summary.keys(Device.keys(this).size, boundOnKeys())
+        ) { startActivity(Intent(this, KeysActivity::class.java)) }
+        col.row(
+            "Power button",
+            Summary.power(Store.bindings(this).all().keys.count { HwKey.POWER in it.keys })
+        ) { startActivity(Intent(this, PowerActivity::class.java)) }
+        col.row(
+            "Two-key combinations",
+            "Hold one key and press another"
+        ) { startActivity(Intent(this, ChordActivity::class.java)) }
+    }
+
+    private fun tools(col: LinearLayout) {
+        col.header("Tools")
+        col.row(
+            "Key tester",
+            "See exactly which keys reach the filter on this device"
+        ) { startActivity(Intent(this, KeyTesterActivity::class.java)) }
+
+        val t = Store.timing(this)
+        col.row(
+            "Gesture timing",
+            "Multi-tap " + t.multiTapMs + " ms · hold " + t.holdMs + " ms · chord " + t.chordMs + " ms"
+        ) { startActivity(Intent(this, TimingActivity::class.java)) }
+
+        if (Device.hasFirmwareKeyHooks) {
+            val hidden = ViwoodsBridge.keys().filter { ViwoodsBridge.hidesFromFilter(this, it) }
+            col.row(
+                "Firmware key hooks",
+                if (hidden.isEmpty()) "What the firmware itself does with each key"
+                else "Hiding " + hidden.joinToString(" and ") { it.label.lowercase() } +
+                    " from this app - tap for the fix",
+                state = if (hidden.isEmpty()) null else "Hiding a key"
+            ) { startActivity(Intent(this, ViwoodsActivity::class.java)) }
+        }
+
+        col.row(
+            "Device report",
+            "Help get this device fully supported - you see everything before it is sent"
+        ) { startActivity(Intent(this, ReportActivity::class.java)) }
+    }
+
+    private fun settingsFile(col: LinearLayout) {
+        col.header("Settings file")
+        col.row(
+            "Export and import",
+            "Save your settings to a file, or share them"
+        ) { startActivity(Intent(this, BackupActivity::class.java)) }
+    }
+
+    private fun shell(col: LinearLayout) {
+        if (!Shell.SUPPORTED) return
+        col.header("For devices with Shizuku or root")
+        col.row(
+            "Shell access",
+            "Optional. More Power button gestures, and system switches",
+            state = Shell.describe(this)
+        ) { startActivity(Intent(this, ShellActivity::class.java)) }
+        if (Shell.ready) {
+            col.row(
+                "Extra-dim light",
+                "Below the lowest the system slider allows"
+            ) { startActivity(Intent(this, DisplayActivity::class.java)) }
+        }
+    }
+
+    private fun boundOnKeys(): Int {
+        val keys = Device.keys(this).toSet()
+        return Store.bindings(this).all().keys.count { t ->
+            HwKey.POWER !in t.keys && t.keys.any { it in keys }
+        }
+    }
+}
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/BackupActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/BackupActivity.kt
index de5a6c9..56ecad8 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/BackupActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/BackupActivity.kt
@@ -16,8 +16,8 @@ import dev.equwal.assistkey.store.SettingsFile
 import dev.equwal.assistkey.store.Store
 import dev.equwal.assistkey.ui.Ui.button
 import dev.equwal.assistkey.ui.Ui.header
+import dev.equwal.assistkey.ui.Ui.more
 import dev.equwal.assistkey.ui.Ui.note
-import dev.equwal.assistkey.ui.Ui.title
 import org.json.JSONObject
 
 /**
@@ -32,6 +32,16 @@ class BackupActivity : Activity() {
         const val SAVE = 1
         const val OPEN = 2
         const val FILE_NAME = "assistkey-settings.json"
+
+        const val ABOUT =
+            "The file holds your key bindings, timing, navigation setup, home " +
+                "screen and voice typing choices.\n\n" +
+                "It does not hold your licence, and nothing about you or your " +
+                "apps beyond the apps you bound to keys or put on the home " +
+                "screen.\n\n" +
+                "An import replaces the settings it contains and leaves the " +
+                "others as they are. A binding can open an app or send an " +
+                "intent, so import files only from people you trust."
     }
 
     override fun onResume() {
@@ -40,13 +50,9 @@ class BackupActivity : Activity() {
     }
 
     private fun build() {
-        val col = Ui.page(this)
-        col.title("Export and import")
-        col.note(
-            "Your key bindings, timing, navigation setup, home screen and voice " +
-                "typing choices, as one file. Use it to move to another device, or to " +
-                "share a setup with other people."
-        )
+        val col = Ui.page(this, "Export and import")
+        col.note("Your settings as one file. Move them to another device, or share them.")
+        col.more("Export and import", ABOUT)
 
         col.header("Export")
         col.button("Save to a file") {
@@ -67,7 +73,6 @@ class BackupActivity : Activity() {
                 0
             )
         }
-        col.note("The file does not contain your licence, and nothing about you or your apps beyond the apps you bound to keys or put on the home screen.")
 
         col.header("Import")
         col.button("Open a file") {
@@ -78,11 +83,7 @@ class BackupActivity : Activity() {
                 ?.primaryClip?.getItemAt(0)?.coerceToText(this)?.toString()
             if (clip.isNullOrBlank()) toast("The clipboard is empty") else offer(clip)
         }
-        col.note(
-            "An import replaces the settings it contains and leaves the others as " +
-                "they are. A binding can open an app or send an intent, so import " +
-                "files only from people you trust."
-        )
+        col.note("A binding can open an app or send an intent, so import files only from people you trust.")
     }
 
     private fun start(i: Intent, code: Int) {
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 2d1b891..1ce1851 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/ChordActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/ChordActivity.kt
@@ -5,12 +5,12 @@ import android.widget.LinearLayout
 import dev.equwal.assistkey.model.HwKey
 import dev.equwal.assistkey.model.Trigger
 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.header
+import dev.equwal.assistkey.ui.Ui.more
 import dev.equwal.assistkey.ui.Ui.note
+import dev.equwal.assistkey.ui.Ui.primaryButton
 import dev.equwal.assistkey.ui.Ui.row
-import dev.equwal.assistkey.ui.Ui.title
 
 /**
  * Builds a key set, then hands it to the ordinary gesture list.
@@ -28,13 +28,8 @@ class ChordActivity : Activity() {
     }
 
     private fun build() {
-        val col = Ui.page(this)
-        col.title("Key combinations")
-        col.note(
-            "Hold one key and press the others within the chord window. Every " +
-                "gesture that works on a single key works on a combination too, " +
-                "including multi-tap and hold."
-        )
+        val col = Ui.page(this, "Key combinations")
+        col.note("Hold one key and press the others within the chord window.")
 
         col.header("Pick the keys")
         HwKey.entries.filter { it.interceptable }.forEach { key ->
@@ -45,7 +40,7 @@ class ChordActivity : Activity() {
         }
 
         if (picked.size >= 2) {
-            col.button("Configure " + picked.joinToString(" + ") { it.label }) {
+            col.primaryButton("Configure " + picked.joinToString(" + ") { it.label }) {
                 startActivity(TriggerListActivity.intent(this, picked.toSet()))
             }
         } else {
@@ -54,12 +49,7 @@ class ChordActivity : Activity() {
 
         existing(col)
 
-        col.header("Why no Power")
-        col.note(
-            "The power key is consumed by the system before any app can see it, " +
-                "so it cannot take part in a combination. Power + Volume up is " +
-                "reserved by the firmware for the power menu."
-        )
+        col.more("Key combinations", ABOUT)
     }
 
     /** Anything already bound, so a chord is easy to find again. */
@@ -79,4 +69,15 @@ class ChordActivity : Activity() {
             ) { startActivity(TriggerListActivity.intent(this, keys)) }
         }
     }
+
+    private companion object {
+        const val ABOUT =
+            "Every gesture that works on a single key works on a combination " +
+                "too, including multi-tap and hold.\n\n" +
+                "Power is absent on purpose. The system takes the Power key " +
+                "before any app can see it, so it can never be half of an " +
+                "ordinary combination.\n\n" +
+                "Power + Volume up is reserved by the firmware for the power " +
+                "menu, and AssistKey leaves it alone as a way out."
+    }
 }
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/DisplayActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/DisplayActivity.kt
index f7062ef..7d0a2a3 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/DisplayActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/DisplayActivity.kt
@@ -7,9 +7,9 @@ import dev.equwal.assistkey.device.Device
 import dev.equwal.assistkey.display.ExtraDim
 import dev.equwal.assistkey.shell.Shell
 import dev.equwal.assistkey.ui.Ui.header
+import dev.equwal.assistkey.ui.Ui.more
 import dev.equwal.assistkey.ui.Ui.note
 import dev.equwal.assistkey.ui.Ui.row
-import dev.equwal.assistkey.ui.Ui.title
 
 /** The frontlight, below where the system's own slider stops. */
 class DisplayActivity : Activity() {
@@ -29,39 +29,51 @@ class DisplayActivity : Activity() {
     }
 
     private fun build() {
-        val col = Ui.page(this)
-        col.title("Extra-dim light")
+        val col = Ui.page(this, "Extra-dim light")
         col.note(
             "The system will not set the light below a fixed floor" +
                 (Device.brightnessFloor?.let { " (" + it + " of 255 on this device)" } ?: "") +
-                ", though the hardware goes lower. These levels sit under that floor, " +
-                "for reading in the dark."
+                ", though the hardware goes lower. These levels sit under that floor."
         )
 
         if (!Shell.ready) {
-            col.row("Shell access: " + Shell.describe(this), "Needed to reach the light directly") {
-                startActivity(Intent(this, ShellActivity::class.java))
-            }
+            col.row(
+                "Shell access",
+                "Needed to reach the light directly",
+                state = Shell.describe(this)
+            ) { startActivity(Intent(this, ShellActivity::class.java)) }
             return
         }
 
         val current = ExtraDim.level(this)
         col.header("Level")
-        col.row((if (current == 0) "* " else "   ") + "Off", "The system slider is in charge") { choose(0) }
+        col.row(
+            "Off",
+            "The system slider is in charge",
+            state = if (current == 0) "In use" else null
+        ) { choose(0) }
         ExtraDim.levels().forEach { v ->
-            col.row((if (current == v) "* " else "   ") + v + " of 255", null) { choose(v) }
+            col.row(v.toString() + " of 255", null, state = if (current == v) "In use" else null) {
+                choose(v)
+            }
         }
-        col.note(
-            "The level holds until the system's brightness slider is moved, and is " +
-                "put back whenever the screen comes on. If this device does not let " +
-                "the shell reach the light, choosing a level will say so."
-        )
-        col.note("To change the level from a key, bind a Light action: Extra dim toggle, darker or brighter. The toggle goes to the lowest level, and back to your system brightness.")
+        col.more("Extra-dim light", ABOUT)
         if (Device.brightnessFloor == null) {
             col.note("This device's floor has not been measured, so these values are a guess.")
         }
     }
 
+    private companion object {
+        const val ABOUT =
+            "The level holds until the system's brightness slider is moved, " +
+                "and is put back whenever the screen comes on. If this device " +
+                "does not let the shell reach the light, choosing a level will " +
+                "say so.\n\n" +
+                "To change the level from a key, bind a Light action: Extra dim " +
+                "toggle, darker or brighter. The toggle goes to the lowest " +
+                "level, and back to your system brightness."
+    }
+
     private fun choose(level: Int) {
         ExtraDim.set(this, level) { ok ->
             if (!ok) {
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/HomeHubActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/HomeHubActivity.kt
new file mode 100644
index 0000000..fd621d8
--- /dev/null
+++ b/app/src/main/java/dev/equwal/assistkey/ui/HomeHubActivity.kt
@@ -0,0 +1,57 @@
+package dev.equwal.assistkey.ui
+
+import android.app.Activity
+import android.content.ComponentName
+import android.content.Intent
+import android.content.pm.PackageManager
+import dev.equwal.assistkey.home.HomeActivity
+import dev.equwal.assistkey.home.HomeSettingsActivity
+import dev.equwal.assistkey.home.RecentsActivity
+import dev.equwal.assistkey.ui.Ui.more
+import dev.equwal.assistkey.ui.Ui.row
+
+/** The two screens AssistKey draws for itself: the home screen, and recents. */
+class HomeHubActivity : Activity() {
+
+    override fun onResume() {
+        super.onResume()
+        build()
+    }
+
+    private fun build() {
+        val col = Ui.page(this, "Home and recents")
+
+        val offered = packageManager.getComponentEnabledSetting(
+            ComponentName(this, HomeActivity::class.java)
+        ) == PackageManager.COMPONENT_ENABLED_STATE_ENABLED
+        val isDefault = packageManager.resolveActivity(
+            Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME),
+            PackageManager.MATCH_DEFAULT_ONLY
+        )?.activityInfo?.packageName == packageName
+
+        col.row(
+            "Home screen",
+            "A clock, the apps you choose, and a line to type in",
+            state = if (isDefault) "In use" else if (offered) "Ready" else "Off"
+        ) { startActivity(Intent(this, HomeSettingsActivity::class.java)) }
+
+        col.row(
+            "Recent apps",
+            "Card switcher made for e-ink, bindable to any key"
+        ) { startActivity(Intent(this, RecentsActivity::class.java)) }
+
+        col.more("Home and recents", ABOUT)
+    }
+
+    private companion object {
+        const val ABOUT =
+            "The home screen keeps the app list out of sight until you type. " +
+                "When one match is left it opens itself. There are no icons and " +
+                "no grid, so there is nothing for an e-ink panel to redraw.\n\n" +
+                "AssistKey is not a home screen until you switch it on and the " +
+                "system asks you to choose.\n\n" +
+                "Recent apps is a row of cards like the Android switcher, drawn " +
+                "with outlines and no animation. Bind it to a key from the action " +
+                "list, under AssistKey."
+    }
+}
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/KeyTesterActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/KeyTesterActivity.kt
index fb5155a..c1779ae 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/KeyTesterActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/KeyTesterActivity.kt
@@ -10,9 +10,9 @@ import dev.equwal.assistkey.channel.Channels
 import dev.equwal.assistkey.engine.KeyLog
 import dev.equwal.assistkey.ui.Ui.button
 import dev.equwal.assistkey.ui.Ui.header
+import dev.equwal.assistkey.ui.Ui.more
 import dev.equwal.assistkey.ui.Ui.note
 import dev.equwal.assistkey.ui.Ui.row
-import dev.equwal.assistkey.ui.Ui.title
 
 /**
  * Shows every key event the filter receives.
@@ -54,27 +54,19 @@ class KeyTesterActivity : Activity() {
 
     private fun build() {
         lastCount = KeyLog.count()
-        val col = Ui.page(this)
-        col.title("Key tester")
+        val col = Ui.page(this, "Key tester")
 
         if (!Channels.isSatisfied(this, Channel.ACCESSIBILITY)) {
-            col.note(
-                "The accessibility key filter is not running, so nothing will " +
-                    "appear here. Turn it on from the main screen first."
-            )
+            col.row(
+                "Key remapping is off",
+                "Nothing will appear here until it is on",
+                state = "Off"
+            ) { startActivity(android.content.Intent(this, SetupActivity::class.java)) }
             return
         }
 
-        col.note(
-            "Press a key. Anything that reaches the filter is listed below, " +
-                "whether or not it has a binding. Power will never appear: the " +
-                "window manager consumes it before any app can see it."
-        )
-        col.note(
-            "Only real presses count. Events injected with adb shell input " +
-                "bypass accessibility filters and will not show up. Keys are " +
-                "listed only while this screen is open, and never stored."
-        )
+        col.note("Press a key. Anything that reaches the filter is listed below.")
+        col.more("Key tester", ABOUT)
 
         col.button("Clear") { KeyLog.clear(); build() }
 
@@ -92,4 +84,16 @@ class KeyTesterActivity : Activity() {
             col.row(e.describe(), "keycode " + e.keyCode, enabled = false)
         }
     }
+
+    private companion object {
+        const val ABOUT =
+            "Every key that reaches the filter is listed, whether or not it " +
+                "has a binding.\n\n" +
+                "Power will never appear. The window manager takes it before " +
+                "any app can see it, on every Android device.\n\n" +
+                "Only real presses count. Events injected with adb shell input " +
+                "bypass accessibility filters entirely and will not show up.\n\n" +
+                "Keys are listed only while this screen is open, and are never " +
+                "stored."
+    }
 }
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/KeysActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/KeysActivity.kt
new file mode 100644
index 0000000..1bdd9cd
--- /dev/null
+++ b/app/src/main/java/dev/equwal/assistkey/ui/KeysActivity.kt
@@ -0,0 +1,95 @@
+package dev.equwal.assistkey.ui
+
+import android.app.Activity
+import android.content.Intent
+import android.widget.LinearLayout
+import dev.equwal.assistkey.channel.Channel
+import dev.equwal.assistkey.channel.Channels
+import dev.equwal.assistkey.device.Device
+import dev.equwal.assistkey.model.GestureType
+import dev.equwal.assistkey.model.HwKey
+import dev.equwal.assistkey.model.Trigger
+import dev.equwal.assistkey.native.ViwoodsBridge
+import dev.equwal.assistkey.store.Store
+import dev.equwal.assistkey.ui.Ui.header
+import dev.equwal.assistkey.ui.Ui.more
+import dev.equwal.assistkey.ui.Ui.row
+
+/**
+ * One row for each key this device has, and a way to build a combination.
+ *
+ * This is the hardware view of the app, for people who want to set a key at a
+ * time. The guided flow reaches the same bindings from the other end.
+ */
+class KeysActivity : Activity() {
+
+    override fun onResume() {
+        super.onResume()
+        build()
+    }
+
+    private fun build() {
+        val col = Ui.page(this, "Keys")
+
+        if (!Channels.isSatisfied(this, Channel.ACCESSIBILITY)) {
+            col.row("Key remapping is off", "Nothing bound here can fire yet", state = "Off") {
+                startActivity(Intent(this, SetupActivity::class.java))
+            }
+        }
+
+        col.header("Keys on this device")
+        Device.keys(this).forEach { key -> keyRow(col, key) }
+
+        col.row("Power", Summary.power(powerBindings())) {
+            startActivity(Intent(this, PowerActivity::class.java))
+        }
+
+        col.header("Together")
+        col.row(
+            "Two-key combinations",
+            "Hold one key and press another - any pair or larger set"
+        ) { startActivity(Intent(this, ChordActivity::class.java)) }
+
+        col.more("Keys", ABOUT)
+    }
+
+    private fun keyRow(col: LinearLayout, key: HwKey) {
+        val hidden = ViwoodsBridge.hidesFromFilter(this, key)
+        col.row(
+            key.label,
+            if (hidden) "A firmware hook is hiding this key - see Firmware key hooks"
+            else boundSummary(setOf(key)),
+            state = if (hidden) "Hidden" else null
+        ) { startActivity(TriggerListActivity.intent(this, setOf(key))) }
+    }
+
+    private fun boundSummary(keys: Set<HwKey>): String {
+        val b = Store.bindings(this)
+        val bound = Trigger.allFor(keys).filter { b.isBound(it) }
+        if (bound.isEmpty()) return "Default behaviour"
+        return bound.joinToString(", ") { t -> shortGesture(t) + " → " + b.raw(t).describe() }
+    }
+
+    private fun shortGesture(t: Trigger): String = when {
+        t.type == GestureType.HOLD -> "hold"
+        t.count == 1 -> "tap"
+        t.count == 2 -> "double"
+        t.count == 3 -> "triple"
+        else -> t.count.toString() + " taps"
+    }
+
+    private fun powerBindings(): Int =
+        Store.bindings(this).all().keys.count { HwKey.POWER in it.keys }
+
+    private companion object {
+        const val ABOUT =
+            "Each key has the same set of gestures: one to five taps, and press " +
+                "and hold. Any of them can be bound on its own.\n\n" +
+                "A key with no bindings is never taken from the system. A key " +
+                "whose highest bound tap count is one fires as soon as it is " +
+                "released, so you only pay the multi-tap wait on keys where you " +
+                "asked for a double tap.\n\n" +
+                "The Power button is not like the others and has a screen of its " +
+                "own."
+    }
+}
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/LicenseActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/LicenseActivity.kt
index 31c1da0..86fa0fa 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/LicenseActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/LicenseActivity.kt
@@ -10,8 +10,8 @@ import dev.equwal.assistkey.license.Sku
 import dev.equwal.assistkey.ui.Ui.button
 import dev.equwal.assistkey.ui.Ui.header
 import dev.equwal.assistkey.ui.Ui.note
+import dev.equwal.assistkey.ui.Ui.primaryButton
 import dev.equwal.assistkey.ui.Ui.row
-import dev.equwal.assistkey.ui.Ui.title
 
 /**
  * Where the licence is shown, bought and restored.
@@ -36,8 +36,7 @@ class LicenseActivity : Activity() {
 
     private fun build() {
         val state = License.state(this)
-        val col = Ui.page(this)
-        col.title("Licence")
+        val col = Ui.page(this, "Licence")
         status(col, state)
         if (state.tier != License.Tier.LICENSED) buy(col, state)
         restore(col)
@@ -116,11 +115,11 @@ class LicenseActivity : Activity() {
                         "You took part in the beta, so the licence is " + testerPrice +
                             (if (fullPrice != null) " instead of " + fullPrice else "") + "."
                     )
-                    col.button("Buy at the tester price - " + testerPrice) {
+                    col.primaryButton("Buy at the tester price - " + testerPrice) {
                         purchase(Sku.PRO_TESTER)
                     }
                 } else if (fullPrice != null) {
-                    col.button("Buy AssistKey - " + fullPrice) { purchase(Sku.PRO) }
+                    col.primaryButton("Buy AssistKey - " + fullPrice) { purchase(Sku.PRO) }
                 }
                 col.note("One payment through Google Play. No subscription, no account.")
             }
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 8e4e80a..15fcdbc 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt
@@ -1,37 +1,51 @@
 package dev.equwal.assistkey.ui
 
 import android.app.Activity
-import android.app.AlertDialog
+import android.content.Context
 import android.content.Intent
+import android.graphics.Typeface
 import android.os.Bundle
+import android.util.TypedValue
+import android.view.Gravity
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
 import android.widget.LinearLayout
-import android.widget.Toast
+import android.widget.TextView
 import dev.equwal.assistkey.channel.Channel
 import dev.equwal.assistkey.channel.Channels
+import dev.equwal.assistkey.home.HomeActivity
 import dev.equwal.assistkey.license.License
 import dev.equwal.assistkey.license.PlayBilling
 import dev.equwal.assistkey.model.GestureType
 import dev.equwal.assistkey.model.HwKey
 import dev.equwal.assistkey.model.Trigger
-import dev.equwal.assistkey.native.PowerNative
-import dev.equwal.assistkey.native.ViwoodsBridge
+import dev.equwal.assistkey.native.NavNative
 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.dp
 import dev.equwal.assistkey.ui.Ui.header
 import dev.equwal.assistkey.ui.Ui.note
+import dev.equwal.assistkey.ui.Ui.primaryButton
 import dev.equwal.assistkey.ui.Ui.row
-import dev.equwal.assistkey.ui.Ui.title
+import dev.equwal.assistkey.ui.Ui.rule
+import dev.equwal.assistkey.ui.Ui.tiles
+import dev.equwal.assistkey.voice.Dictation
 
 /**
- * The whole configuration surface.
+ * The hub, and the only screen the app opens on.
+ *
+ * It fits on one screen at 412 x 824 dp and says three things: what the licence
+ * is, the one action that sets up a button, and what is bound now. Everything
+ * else is a tile that leads to a screen of its own.
  *
  * Rebuilt in onResume rather than onCreate, because almost every setup step
  * happens in another app - Settings, a role dialog - and the user comes back
- * expecting the status lines to have caught up.
+ * expecting the state lines to have caught up.
  */
 class MainActivity : Activity() {
 
+    /** How many bindings the hub lists before it points at Advanced. */
+    private val listed = 4
+
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         Channels.syncComponents(this)
@@ -51,227 +65,150 @@ class MainActivity : Activity() {
 
     private fun build() {
         val col = Ui.page(this)
-        col.title("AssistKey")
-        col.note(
-            "Remaps this device's hardware keys, the Power button included."
-        )
-        licence(col)
-        channels(col)
-        buttons(col)
-        extras(col)
+        appBar(col)
+        col.rule()
+        callToAction(col)
+        yourButtons(col)
+        hubTiles(col)
     }
 
-    // ---- licence -----------------------------------------------------------
+    // ---- app bar -----------------------------------------------------------
 
-    private fun licence(col: LinearLayout) {
+    /** The name, and the licence state beside it. The whole bar opens Licence. */
+    private fun appBar(col: LinearLayout) {
         val s = License.state(this)
-        val (title, subtitle) = when (s.tier) {
-            License.Tier.LICENSED -> "Unlocked" to "Thank you for buying AssistKey"
-            License.Tier.BETA ->
-                "Beta - free for now" to "Everything works while the beta is open"
-            License.Tier.TRIAL ->
-                ("Trial - " + s.trialDaysLeft + (if (s.trialDaysLeft == 1) " day" else " days") +
-                    " left") to "Tap to buy a licence"
-            License.Tier.LOCKED ->
-                "Locked - remapping is off" to "Tap to unlock. Your bindings are kept."
-        }
-        col.row(title, subtitle) { startActivity(Intent(this, LicenseActivity::class.java)) }
+        val bar = LinearLayout(this).apply {
+            orientation = LinearLayout.HORIZONTAL
+            gravity = Gravity.CENTER_VERTICAL
+            minimumHeight = dp(56)
+            isClickable = true
+            setOnClickListener { startActivity(Intent(this@MainActivity, LicenseActivity::class.java)) }
+        }
+        bar.addView(TextView(this).apply {
+            text = "AssistKey"
+            setTextColor(Ui.INK)
+            setTextSize(TypedValue.COMPLEX_UNIT_SP, 24f)
+            setTypeface(Typeface.DEFAULT_BOLD)
+        }, LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f))
+        bar.addView(Ui.chip(this, Summary.licenceChip(s.tier, s.trialDaysLeft)))
+        col.addView(bar, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT))
     }
 
-    // ---- channels ----------------------------------------------------------
-
-    private fun channels(col: LinearLayout) {
-        col.header("Setup")
-
-        // The key filter is the one switch everything depends on: it sees the
-        // keys, and it is what carries out Back, Home and the rest.
-        val ch = Channel.ACCESSIBILITY
-        val on = Channels.isEnabled(this, ch)
-        val ok = Channels.isSatisfied(this, ch)
-        col.check("Key remapping", "Uses the accessibility key filter - " + Channels.status(this, ch).lowercase(), on) { checked ->
-            Channels.setEnabled(this, ch, checked)
-            if (checked && !Channels.isSatisfied(this, ch)) claim(ch)
-            build()
-        }
-        if (on && !ok) {
-            col.button("Turn on the key filter") { claim(ch) }
-            restrictedSettingsHint(col)
-        }
-
-        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))
-        }
-
-    }
+    // ---- the one action ----------------------------------------------------
 
     /**
-     * Sideloaded apps cannot be given accessibility access until the user
-     * clears Android's restricted-settings block, and the symptom is that the
-     * toggle appears to work and then quietly reverts. Nothing can detect this,
-     * so it gets called out wherever it would bite.
+     * Press a button, choose what it does. The flow finds the way in by itself,
+     * so nobody has to know what a channel or a role is.
      */
-    private fun restrictedSettingsHint(col: LinearLayout) {
-        col.note(
-            "If the switch turns itself back off, Android is blocking it " +
-                "because this app was installed outside an app store. 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))
+    private fun callToAction(col: LinearLayout) {
+        col.primaryButton("Set up a button") {
+            startActivity(
+                Intent().setClassName(this, "dev.equwal.assistkey.setup.GuidedSetupActivity")
+            )
         }
+        col.note("Pick a button, choose what it does, and AssistKey does the rest.")
     }
 
-    /**
-     * Google Play requires that an app using the accessibility API for anything
-     * other than assistive technology says so, in the app, in plain words, and
-     * gets a yes before sending anyone to the switch. It is also simply the
-     * right thing to do with a permission this broad.
-     */
-    private fun claim(ch: Channel) {
-        if (ch != Channel.ACCESSIBILITY) return claimNow(ch)
-        AccessibilityDisclosure.show(this, onAgree = { claimNow(ch) })
-    }
-
-    private fun claimNow(ch: Channel) {
-        if (ch == Channel.CAMERA) {
-            Toast.makeText(
-                this,
-                "Choose Camera app, then pick AssistKey",
-                Toast.LENGTH_LONG
-            ).show()
+    // ---- what is bound now -------------------------------------------------
+
+    private fun yourButtons(col: LinearLayout) {
+        col.header("Your buttons")
+        val all = Store.bindings(this).all().entries
+            .sortedWith(
+                compareBy(
+                    { t -> t.key.keys.minOf { it.ordinal } },
+                    { t -> t.key.keys.size },
+                    { t -> t.key.type.ordinal },
+                    { t -> t.key.count }
+                )
+            )
+        if (all.isEmpty()) {
+            col.note("Nothing is bound yet. Set up a button to make a start.")
+            return
+        }
+        val overflow = all.size > listed
+        val shown = all.take(if (overflow) listed - 1 else listed)
+        shown.forEach { (trigger, spec) ->
+            col.row(Summary.binding(plainGesture(trigger), spec.describe())) {
+                startActivity(ActionPickerActivity.intent(this, trigger))
+            }
         }
-        if (!Channels.safeStart(this, Channels.claimIntent(this, ch))) {
-            Toast.makeText(this, "No settings screen for that on this firmware", Toast.LENGTH_LONG)
-                .show()
+        if (overflow) {
+            col.row(
+                (all.size - shown.size).toString() + " more",
+                "See them all under Advanced > Full control"
+            ) { startActivity(Intent(this, AdvancedActivity::class.java)) }
         }
     }
 
-    // ---- bindings ----------------------------------------------------------
-
-    private fun buttons(col: LinearLayout) {
-        col.header("Buttons")
-
-        dev.equwal.assistkey.device.Device.keys(this).forEach { key ->
-            val summary =
-                if (ViwoodsBridge.hidesFromFilter(this, key)) {
-                    "Hidden by a firmware hook - see Firmware key hooks below"
-                } else {
-                    boundSummary(setOf(key))
+    private fun plainGesture(t: Trigger): String = Summary.gesture(
+        t.keys.sortedBy { it.ordinal }.map { it.label },
+        t.type == GestureType.HOLD,
+        t.count
+    )
+
+    // ---- tiles -------------------------------------------------------------
+
+    private fun hubTiles(col: LinearLayout) {
+        col.tiles(
+            listOf(
+                Ui.Tile("Navigation", navigationSummary()) {
+                    startActivity(Intent(this, NavigationActivity::class.java))
+                },
+                Ui.Tile("Voice typing", voiceSummary()) {
+                    startActivity(Intent(this, VoiceActivity::class.java))
+                },
+                Ui.Tile("Home and recents", homeSummary()) {
+                    startActivity(Intent(this, HomeHubActivity::class.java))
+                },
+                Ui.Tile("Setup", setupSummary()) {
+                    startActivity(Intent(this, SetupActivity::class.java))
+                },
+                Ui.Tile("Advanced", "Every key, the Power button, testing and backup") {
+                    startActivity(Intent(this, AdvancedActivity::class.java))
                 }
-            col.row(key.label, summary) {
-                startActivity(TriggerListActivity.intent(this, setOf(key)))
-            }
-        }
-
-        col.row("Power", powerSummary()) { startActivity(Intent(this, PowerActivity::class.java)) }
-
-        col.row(
-            "Navigation",
-            if (dev.equwal.assistkey.shell.Shell.SUPPORTED) "Button bar, swipe gestures, the Power key - in any mix"
-            else "The Power key, and how to switch the button bar and gestures"
-        ) { startActivity(Intent(this, NavigationActivity::class.java)) }
-
-        col.row(
-            "Two-key combinations",
-            "Hold one key and press another - any pair or larger set"
-        ) { startActivity(Intent(this, ChordActivity::class.java)) }
+            )
+        )
     }
 
-    private fun boundSummary(keys: Set<HwKey>): String {
-        val b = Store.bindings(this)
-        val bound = Trigger.allFor(keys).filter { b.isBound(it) }
-        if (bound.isEmpty()) return "Default behaviour"
-        return bound.joinToString(", ") { t ->
-            shortGesture(t) + " → " + b.raw(t).describe()
-        }
+    private fun navigationSummary(): String {
+        val p = getSharedPreferences("assistkey_nav", Context.MODE_PRIVATE)
+        val buttonsNow = NavNative.buttonsShowing(this)
+        val gesturesNow = NavNative.gesturesOn(this) ?: buttonsNow?.not()
+        val power = setOf(HwKey.POWER)
+        return Summary.navigation(
+            p.getBoolean("buttons", buttonsNow ?: true),
+            p.getBoolean("gestures", gesturesNow ?: true),
+            p.getBoolean("keys", Store.bindings(this).all().keys.any { it.keys == power })
+        )
     }
 
-    private fun shortGesture(t: Trigger): String = when {
-        t.type == GestureType.HOLD -> "hold"
-        t.count == 1 -> "tap"
-        t.count == 2 -> "double"
-        t.count == 3 -> "triple"
-        else -> t.count.toString() + " taps"
+    private fun voiceSummary(): String {
+        val engines = Dictation.engines(this)
+        return Summary.voice(
+            Dictation.hasMicrophone(this),
+            engines.size,
+            engines.any(Dictation::isOnDevice)
+        )
     }
 
-    private fun powerSummary(): String {
-        val bound = Store.bindings(this).all().filterKeys { HwKey.POWER in it.keys }
-        if (bound.isEmpty()) return "Default behaviour"
-        return bound.entries.joinToString(", ") { (t, spec) ->
-            val what = if (t.keys.size > 1) "+" + (t.keys - HwKey.POWER).first().label.lowercase() else shortGesture(t)
-            what + " → " + spec.describe()
-        }
+    private fun homeSummary(): String {
+        val cn = android.content.ComponentName(this, HomeActivity::class.java)
+        val offered = packageManager.getComponentEnabledSetting(cn) ==
+            android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_ENABLED
+        val isDefault = packageManager.resolveActivity(
+            Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME),
+            android.content.pm.PackageManager.MATCH_DEFAULT_ONLY
+        )?.activityInfo?.packageName == packageName
+        return Summary.homeAndRecents(offered, isDefault)
     }
 
-    // ---- everything else ---------------------------------------------------
-
-    private fun extras(col: LinearLayout) {
-        col.header("Typing")
-        col.row("Voice typing", "Speak into any text field, from a key") {
-            startActivity(Intent(this, VoiceActivity::class.java))
-        }
-
-        col.header("Display and home")
-        col.row("Home screen", "A plain, fast launcher made for e-ink") {
-            startActivity(Intent(this, dev.equwal.assistkey.home.HomeSettingsActivity::class.java))
-        }
-        col.row("Recent apps", "Card switcher made for e-ink, bindable to any key") {
-            startActivity(Intent(this, dev.equwal.assistkey.home.RecentsActivity::class.java))
-        }
-
-        col.header("Advanced")
-
-        if (dev.equwal.assistkey.device.Device.hasFirmwareKeyHooks) {
-            val hidden = ViwoodsBridge.keys().filter { ViwoodsBridge.hidesFromFilter(this, it) }
-            col.row(
-                "Firmware key hooks",
-                if (hidden.isEmpty()) "What the firmware itself does with each key"
-                else "Hiding " + hidden.joinToString(" and ") { it.label.lowercase() } +
-                    " from this app - tap for the fix"
-            ) { 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))
-        }
-
-        col.row(
-            "Device report",
-            "Help get this device fully supported - you see everything before it is sent"
-        ) { startActivity(Intent(this, ReportActivity::class.java)) }
-
-        col.row(
-            "Key tester",
-            "See exactly which keys reach the filter on this device"
-        ) { startActivity(Intent(this, KeyTesterActivity::class.java)) }
-
-        val t = Store.timing(this)
-        col.row(
-            "Gesture timing",
-            "Multi-tap " + t.multiTapMs + " ms · hold " + t.holdMs +
-                " ms · chord " + t.chordMs + " ms"
-        ) { startActivity(Intent(this, TimingActivity::class.java)) }
-
-        col.header("If you get stuck")
-        col.note(
-            "Power + Volume up opens the power menu, whatever else is bound. " +
-                "Uninstalling the app restores every key to its firmware " +
-                "behaviour; nothing it does outlives it."
+    private fun setupSummary(): String {
+        val granted = PermissionsActivity.granted(this)
+        return Summary.setup(
+            Channels.isSatisfied(this, Channel.ACCESSIBILITY),
+            granted.count { it },
+            granted.size
         )
     }
 }
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/NavigationActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/NavigationActivity.kt
index 119ba06..78d06a7 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/NavigationActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/NavigationActivity.kt
@@ -23,9 +23,9 @@ 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.more
 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 button bar, swipe gestures, the Power key - any mix.
@@ -148,8 +148,7 @@ class NavigationActivity : Activity() {
         val wantGestures = wanted("gestures", gesturesNow ?: true)
         val wantKeys = wanted("keys", keysOn())
 
-        val col = Ui.page(this)
-        col.title("Navigation")
+        val col = Ui.page(this, "Navigation")
         col.note("The button bar, swipe gestures and the Power key, in any mix.")
 
         col.check("Button bar", "Back, Home and Recents along the bottom edge", wantButtons) {
@@ -207,15 +206,17 @@ class NavigationActivity : Activity() {
             startActivity(Intent(this, PowerActivity::class.java))
         }
         if (!Channels.isSatisfied(this, Channel.ACCESSIBILITY)) {
-            col.note("Actions like Back and Home are carried out by the accessibility key filter, which is off. Turn it on from the main screen.")
+            col.row("Key remapping is off", "Back and Home cannot fire yet", state = "Off") {
+                startActivity(Intent(this, SetupActivity::class.java))
+            }
         }
         if (!direct) {
             col.note(
-                (if (Shell.SUPPORTED) "Without shell access a tap cannot reach any app. " else "A tap cannot reach any app. ") +
-                    "Hold works once AssistKey is the digital assistant, and double " +
-                    "press once it is the default camera app - both are set up on the " +
-                    "Power button screen."
+                (if (Shell.SUPPORTED) "Without shell access a tap cannot reach any app. "
+                else "A tap cannot reach any app. ") +
+                    "Hold and double press still work."
             )
+            col.more("The Power key as navigation", POWER_KEY_ABOUT)
         }
     }
 
@@ -227,8 +228,8 @@ class NavigationActivity : Activity() {
         gesturesNow: Boolean?
     ) {
         col.header("Button bar and gestures")
-        col.row("Button bar: " + now(buttonsNow, "showing", "hidden"), null, enabled = false)
-        col.row("Swipe-up gesture: " + now(gesturesNow, "on", "off"), null, enabled = false)
+        col.row("Button bar", null, enabled = false, state = now(buttonsNow, "Showing", "Hidden"))
+        col.row("Swipe-up gesture", null, enabled = false, state = now(gesturesNow, "On", "Off"))
 
         if (applying) {
             col.note("Applying... the screen may redraw once.")
@@ -242,9 +243,11 @@ class NavigationActivity : Activity() {
         }
         if (Shell.SUPPORTED) {
             col.note("Android does not let apps switch these. Shell access lets AssistKey do it from here:")
-            col.row("Shell access: " + Shell.describe(this), "Set it up once, on the device, with no computer") {
-                startActivity(Intent(this, ShellActivity::class.java))
-            }
+            col.row(
+                "Shell access",
+                "Set it up once, on the device, with no computer",
+                state = Shell.describe(this)
+            ) { startActivity(Intent(this, ShellActivity::class.java)) }
             col.note("Or, with a computer and USB debugging:")
         } else {
             col.note(
@@ -258,6 +261,20 @@ class NavigationActivity : Activity() {
     private fun now(v: Boolean?, yes: String, no: String): String = when (v) {
         true -> yes
         false -> no
-        null -> "unknown"
+        null -> "Unknown"
+    }
+
+    private companion object {
+        const val POWER_KEY_ABOUT =
+            "The window manager takes the Power key before any app can see it, " +
+                "so a single tap cannot reach AssistKey.\n\n" +
+                "Press and hold arrives as an assistant request, so it works " +
+                "once AssistKey is the digital assistant.\n\n" +
+                "Double press arrives as a camera launch, so it works once " +
+                "AssistKey is the default camera app.\n\n" +
+                "Both are set up on the Power button screen.\n\n" +
+                "Back, Home and Recents on a Power gesture keep working when " +
+                "the app is locked, so a reader with no bar and no gestures is " +
+                "never left without a way to navigate."
     }
 }
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/PermissionsActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/PermissionsActivity.kt
index 452812f..63fa6ee 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/PermissionsActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/PermissionsActivity.kt
@@ -14,9 +14,10 @@ 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.more
 import dev.equwal.assistkey.ui.Ui.note
+import dev.equwal.assistkey.ui.Ui.primaryButton
 import dev.equwal.assistkey.ui.Ui.row
-import dev.equwal.assistkey.ui.Ui.title
 import dev.equwal.assistkey.voice.Dictation
 
 /**
@@ -36,7 +37,7 @@ class PermissionsActivity : Activity() {
     private class Item(
         val title: String,
         val why: String,
-        val granted: () -> Boolean,
+        val granted: Boolean,
         val ask: () -> Unit
     )
 
@@ -44,11 +45,12 @@ class PermissionsActivity : Activity() {
     private var asked = HashSet<String>()
 
     private fun items(): List<Item> {
+        val state = granted(this)
         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) }
+            state[0]
         ) {
             Channels.setEnabled(this, Channel.ACCESSIBILITY, true)
             AccessibilityDisclosure.show(
@@ -60,7 +62,7 @@ class PermissionsActivity : Activity() {
         list += Item(
             "Digital assistant",
             "So that holding the Power button reaches this app.",
-            { Channels.isSatisfied(this, Channel.ASSISTANT) }
+            state[1]
         ) {
             Channels.setEnabled(this, Channel.ASSISTANT, true)
             start(Channels.claimIntent(this, Channel.ASSISTANT))
@@ -68,12 +70,12 @@ class PermissionsActivity : Activity() {
         list += Item(
             "Microphone",
             "For Voice typing only. A speech app on this device does the listening.",
-            { Dictation.hasMicrophone(this) }
+            state[2]
         ) { 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 }
+            state[3]
         ) { start(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)) }
         return list
     }
@@ -102,7 +104,7 @@ class PermissionsActivity : Activity() {
         val now = android.os.SystemClock.uptimeMillis()
         if (now - lastStep < 600) return
         lastStep = now
-        val item = items().firstOrNull { !it.granted() && it.title !in asked }
+        val item = items().firstOrNull { !it.granted && it.title !in asked }
         if (item == null) {
             walking = false
             build()
@@ -118,45 +120,27 @@ class PermissionsActivity : Activity() {
 
     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."
-        )
+        val missing = all.count { !it.granted }
+        val col = Ui.page(this, "Permissions")
+        col.note("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 + ")") {
+            col.primaryButton("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.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.note("Android blocks some switches for apps that were installed from a file.")
         col.button("Open App info") { Channels.safeStart(this, Channels.appInfoIntent(this)) }
+        col.more("Restricted settings", RESTRICTED)
 
         if (!PowerNative.canWriteSecure(this)) {
             col.header("From a computer, once")
@@ -170,8 +154,7 @@ class PermissionsActivity : Activity() {
     }
 
     private fun entry(col: LinearLayout, item: Item) {
-        val ok = item.granted()
-        col.row((if (ok) "Granted: " else "Missing: ") + item.title, item.why) {
+        col.row(item.title, item.why, state = if (item.granted) "Granted" else "Missing") {
             walking = false
             item.ask()
         }
@@ -181,6 +164,25 @@ class PermissionsActivity : Activity() {
         private const val PREFS = "assistkey_setup"
         private const val K_SHOWN = "permissions_shown"
 
+        private const val RESTRICTED =
+            "Android blocks restricted settings for an app that was installed " +
+                "outside an app store, and accessibility is one of them. The " +
+                "symptom is silent: the switch appears to turn on, then reverts " +
+                "a moment later with no message.\n\n" +
+                "Open App info, tap the three-dot menu, choose Allow restricted " +
+                "settings, then try again."
+
+        /**
+         * Whether each grant is in place, in the order this screen lists them.
+         * The hub and the Setup screen count these, so there is one list.
+         */
+        fun granted(c: Context): List<Boolean> = listOf(
+            Channels.isSatisfied(c, Channel.ACCESSIBILITY),
+            Channels.isSatisfied(c, Channel.ASSISTANT),
+            Dictation.hasMicrophone(c),
+            Apps.hasUsageAccess(c) || Shell.ready
+        )
+
         /** 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)
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 82b7493..28442bd 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/PowerActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/PowerActivity.kt
@@ -19,9 +19,9 @@ 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.more
 import dev.equwal.assistkey.ui.Ui.note
 import dev.equwal.assistkey.ui.Ui.row
-import dev.equwal.assistkey.ui.Ui.title
 
 /**
  * The Power button.
@@ -53,8 +53,7 @@ class PowerActivity : Activity() {
     }
 
     private fun build() {
-        val col = Ui.page(this)
-        col.title("Power button")
+        val col = Ui.page(this, "Power button")
         val direct = Shell.ready && PowerControl.wanted(this)
         if (direct) direct(col) else sideDoors(col)
         emergencySos(col)
@@ -92,11 +91,7 @@ class PowerActivity : Activity() {
     // ---- with shell access ----------------------------------------------------------------
 
     private fun direct(col: LinearLayout) {
-        col.note(
-            "AssistKey is reading the Power button directly. While anything is " +
-                "bound here the system's own reactions are switched off, so a " +
-                "gesture you leave unbound locks the screen, as Power always did."
-        )
+        col.note("AssistKey is reading the Power button directly.")
 
         col.header("Taps")
         bindRow(col, "Tap", Trigger(power, GestureType.TAP, 1))
@@ -104,10 +99,6 @@ class PowerActivity : Activity() {
         bindRow(col, "Triple tap", Trigger(power, GestureType.TAP, 3))
         bindRow(col, "4 taps", Trigger(power, GestureType.TAP, 4))
         bindRow(col, "5 taps", Trigger(power, GestureType.TAP, 5))
-        col.note(
-            "A tap waits a moment for a second one only if double tap or more is " +
-                "bound. The wait is set under Gesture timing."
-        )
 
         col.header("Hold")
         bindRow(col, "Press and hold", Trigger(power, GestureType.HOLD))
@@ -118,12 +109,8 @@ class PowerActivity : Activity() {
         }
 
         col.header("Safety")
-        col.note(
-            "Power + Volume up always opens the power menu. The press that wakes " +
-                "the screen is never treated as a gesture. If shell access is lost - " +
-                "after a restart, say - the button goes back to the system until it " +
-                "returns."
-        )
+        col.note("Power + Volume up always opens the power menu.")
+        col.more("The Power button, read directly", DIRECT_ABOUT)
         col.check(
             "Let AssistKey handle the Power button",
             "Untick to give it back to the system and use the side doors instead",
@@ -142,30 +129,22 @@ class PowerActivity : Activity() {
     // ---- without it --------------------------------------------------------------------------
 
     private fun sideDoors(col: LinearLayout) {
-        col.note(
-            "Android does not show the Power button to apps. Two side doors are " +
-                "open: press and hold, and double press."
-        )
+        col.note("Android does not show the Power button to apps. Two side doors are open.")
+        col.more("The two side doors", SIDE_DOORS_ABOUT)
+
         col.header("Hold - through the assistant door")
         door(col, Channel.ASSISTANT)
         bindRow(col, "Press and hold", Channel.POWER_HOLD)
         dev.equwal.assistkey.device.Device.keys(this).forEach { key ->
             bindRow(col, "Power held, then " + Ui.inSentence(key), Trigger.powerThen(key))
         }
-        col.note(
-            "While a held-then-key combination is bound, plain hold waits a second " +
-                "to see whether a key follows."
-        )
 
         col.header("Double press - through the camera door")
         door(col, Channel.CAMERA)
         bindRow(col, "Double press", Channel.POWER_DOUBLE)
 
         col.header("Tap")
-        col.note(
-            "A single tap cannot reach any app this way. The system's own choices " +
-                "are all there is:"
-        )
+        col.note("A single tap cannot reach any app this way. The system's own choices are all there is.")
         firmware(col)
 
         col.header("Wallet door")
@@ -252,4 +231,30 @@ class PowerActivity : Activity() {
         Toast.makeText(this, if (ok) "Applied" else "The system refused", Toast.LENGTH_SHORT).show()
         build()
     }
+
+    private companion object {
+
+        const val DIRECT_ABOUT =
+            "While anything is bound here, the system's own reactions to the " +
+                "Power button are switched off. A gesture you leave unbound " +
+                "locks the screen, as Power always did.\n\n" +
+                "A tap waits a moment for a second one only if double tap or " +
+                "more is bound. The wait is set under Advanced > Gesture " +
+                "timing.\n\n" +
+                "Power + Volume up always opens the power menu. The press that " +
+                "wakes the screen is never treated as a gesture.\n\n" +
+                "If shell access is lost - after a restart, say - the button " +
+                "goes back to the system until it returns."
+
+        const val SIDE_DOORS_ABOUT =
+            "The window manager takes the Power key before any app can see it, " +
+                "so no accessibility service reaches it on any Android " +
+                "version. Two side doors are left.\n\n" +
+                "Press and hold arrives as an assistant request, so it works " +
+                "once AssistKey holds the digital assistant role.\n\n" +
+                "Double press arrives as a camera launch, so it works once " +
+                "AssistKey is the default camera app.\n\n" +
+                "While a held-then-key combination is bound, plain hold waits " +
+                "one second to see whether a key follows."
+    }
 }
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/ReportActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/ReportActivity.kt
index 2d4d946..5b32c32 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/ReportActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/ReportActivity.kt
@@ -18,7 +18,7 @@ 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.title
+import dev.equwal.assistkey.ui.Ui.primaryButton
 
 /**
  * A description of this device, for getting it supported.
@@ -49,14 +49,12 @@ class ReportActivity : Activity() {
     }
 
     private fun build(text: String) {
-        val col = Ui.page(this)
-        col.title("Device report")
+        val col = Ui.page(this, "Device report")
         col.note(
-            "Help get " + Device.name + " fully supported. This is everything the " +
-                "report contains - nothing is sent unless you send it, and AssistKey " +
-                "has no internet permission to send it with."
+            "Help get " + Device.name + " fully supported. Nothing is sent unless " +
+                "you send it, and AssistKey has no internet permission to send it with."
         )
-        col.button("Send by email") {
+        col.primaryButton("Send by email") {
             val i = Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:" + to))
                 .putExtra(Intent.EXTRA_SUBJECT, "AssistKey device report: " + Device.name)
                 .putExtra(Intent.EXTRA_TEXT, report)
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/SetupActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/SetupActivity.kt
new file mode 100644
index 0000000..cdd00c5
--- /dev/null
+++ b/app/src/main/java/dev/equwal/assistkey/ui/SetupActivity.kt
@@ -0,0 +1,108 @@
+package dev.equwal.assistkey.ui
+
+import android.app.Activity
+import android.content.Intent
+import android.widget.LinearLayout
+import android.widget.Toast
+import dev.equwal.assistkey.channel.Channel
+import dev.equwal.assistkey.channel.Channels
+import dev.equwal.assistkey.license.License
+import dev.equwal.assistkey.ui.Ui.button
+import dev.equwal.assistkey.ui.Ui.check
+import dev.equwal.assistkey.ui.Ui.header
+import dev.equwal.assistkey.ui.Ui.more
+import dev.equwal.assistkey.ui.Ui.note
+import dev.equwal.assistkey.ui.Ui.row
+
+/**
+ * The master switch, what the app has been allowed to do, and the licence.
+ *
+ * Key remapping comes first because nothing else works without it.
+ */
+class SetupActivity : Activity() {
+
+    override fun onResume() {
+        super.onResume()
+        build()
+    }
+
+    private fun build() {
+        val col = Ui.page(this, "Setup")
+
+        val ch = Channel.ACCESSIBILITY
+        val on = Channels.isEnabled(this, ch)
+        val ok = Channels.isSatisfied(this, ch)
+        col.check(
+            "Key remapping",
+            if (ok) "On. Your bindings can fire." else Channels.status(this, ch),
+            on
+        ) { checked ->
+            Channels.setEnabled(this, ch, checked)
+            if (checked && !Channels.isSatisfied(this, ch)) claim(ch)
+            build()
+        }
+        if (on && !ok) {
+            col.button("Turn on the key filter") { claim(ch) }
+            col.note("If the switch turns itself back off, Android is blocking it.")
+            col.button("Open App info") {
+                Channels.safeStart(this, Channels.appInfoIntent(this))
+            }
+            col.more("Restricted settings", RESTRICTED)
+        }
+
+        val granted = PermissionsActivity.granted(this)
+        col.row(
+            "Permissions",
+            "What the app may do, and a way to grant it again",
+            state = if (granted.all { it }) "All granted"
+            else (granted.size - granted.count { it }).toString() + " missing"
+        ) { startActivity(Intent(this, PermissionsActivity::class.java)) }
+
+        val s = License.state(this)
+        col.row(
+            "Licence",
+            Summary.licence(s.tier, s.trialDaysLeft),
+            state = Summary.licenceChip(s.tier, s.trialDaysLeft)
+        ) { startActivity(Intent(this, LicenseActivity::class.java)) }
+
+        stuck(col)
+    }
+
+    private fun stuck(col: LinearLayout) {
+        col.header("If you get stuck")
+        col.note("Power + Volume up opens the power menu, whatever else is bound.")
+        col.more("If you get stuck", STUCK)
+    }
+
+    private fun claim(ch: Channel) {
+        if (ch != Channel.ACCESSIBILITY) return claimNow(ch)
+        AccessibilityDisclosure.show(this, onAgree = { claimNow(ch) })
+    }
+
+    private fun claimNow(ch: Channel) {
+        if (ch == Channel.CAMERA) {
+            Toast.makeText(this, "Choose Camera app, then pick AssistKey", Toast.LENGTH_LONG).show()
+        }
+        if (!Channels.safeStart(this, Channels.claimIntent(this, ch))) {
+            Toast.makeText(this, "No settings screen for that on this firmware", Toast.LENGTH_LONG)
+                .show()
+        }
+    }
+
+    private companion object {
+
+        const val RESTRICTED =
+            "Android blocks restricted settings for an app that was installed " +
+                "outside an app store, and accessibility is one of them. The " +
+                "symptom is silent: the switch appears to turn on, then reverts " +
+                "a moment later with no message.\n\n" +
+                "Open App info, tap the three-dot menu, choose Allow restricted " +
+                "settings, then try again."
+
+        const val STUCK =
+            "Power + Volume up always opens the power menu, whatever else is " +
+                "bound to those keys. It is the way out of any setup.\n\n" +
+                "Uninstalling AssistKey restores every key to its firmware " +
+                "behaviour. The app writes nothing that outlives it."
+    }
+}
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/ShellActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/ShellActivity.kt
index 4f37f1d..32a718d 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/ShellActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/ShellActivity.kt
@@ -9,9 +9,9 @@ import dev.equwal.assistkey.channel.Channels
 import dev.equwal.assistkey.shell.Shell
 import dev.equwal.assistkey.ui.Ui.button
 import dev.equwal.assistkey.ui.Ui.header
+import dev.equwal.assistkey.ui.Ui.more
 import dev.equwal.assistkey.ui.Ui.note
 import dev.equwal.assistkey.ui.Ui.row
-import dev.equwal.assistkey.ui.Ui.title
 
 /**
  * Getting shell access, from the device itself.
@@ -39,18 +39,10 @@ class ShellActivity : Activity() {
 
     private fun build() {
         val state = Shell.state(this)
-        val col = Ui.page(this)
-        col.title("Shell access")
-        col.row("Status: " + Shell.describe(this), null, enabled = false)
-
-        col.note(
-            "Android keeps a few things from every installed app: the Power " +
-                "button, the navigation bar, the system gestures, a device maker's " +
-                "own key settings. The shell user - the one a computer gets over " +
-                "USB debugging - can reach all of them. Shizuku, a free app, gives " +
-                "AssistKey that same access from the device itself, with no " +
-                "computer and no root."
-        )
+        val col = Ui.page(this, "Shell access")
+        col.row("Status", null, enabled = false, state = Shell.describe(this))
+        col.note("Shizuku, a free app, gives AssistKey the shell user from the device itself.")
+        col.more("Shell access", ABOUT)
 
         if (state == Shell.State.READY) {
             col.header("What this unlocks")
@@ -61,11 +53,7 @@ class ShellActivity : Activity() {
                     "gestures, from inside the app.\n" +
                     "Device key settings that would otherwise need a computer."
             )
-            col.note(
-                "Shizuku started over wireless debugging stops when the device " +
-                    "restarts. Until it is started again AssistKey gives the Power " +
-                    "button back to the system, so it always works."
-            )
+            col.more("After a restart", RESTART)
             return
         }
 
@@ -119,4 +107,24 @@ class ShellActivity : Activity() {
 
     private fun tryStart(i: Intent): Boolean =
         runCatching { startActivity(i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); true }.getOrDefault(false)
+
+    private companion object {
+
+        const val ABOUT =
+            "Android keeps a few things from every installed app: the Power " +
+                "button, the navigation bar, the system gestures, a device " +
+                "maker's own key settings.\n\n" +
+                "The shell user - the one a computer gets over USB debugging - " +
+                "can reach all of them. Shizuku is a free app that pairs with " +
+                "Android's own wireless debugging and then runs a small server " +
+                "as that user. So AssistKey gets the same access from the " +
+                "device itself, with no computer and no root.\n\n" +
+                "Everything that depends on shell access falls back by itself " +
+                "when there is none."
+
+        const val RESTART =
+            "Shizuku started over wireless debugging stops when the device " +
+                "restarts. Until it is started again, AssistKey gives the Power " +
+                "button back to the system, so the button always works."
+    }
 }
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/Summary.kt b/app/src/main/java/dev/equwal/assistkey/ui/Summary.kt
new file mode 100644
index 0000000..7c773ed
--- /dev/null
+++ b/app/src/main/java/dev/equwal/assistkey/ui/Summary.kt
@@ -0,0 +1,93 @@
+package dev.equwal.assistkey.ui
+
+import dev.equwal.assistkey.license.License
+
+/**
+ * The short state lines the hub shows: one line for each tile, and one line
+ * for each binding in the "Your buttons" list.
+ *
+ * Every function here takes plain values and returns text. It holds no Android
+ * type, so the wording is tested on the computer.
+ */
+object Summary {
+
+    /** "Power, hold" - the gesture, in the words a person would use. */
+    fun gesture(keys: List<String>, hold: Boolean, taps: Int): String {
+        val what = when {
+            hold -> "hold"
+            taps <= 1 -> "tap"
+            taps == 2 -> "double tap"
+            taps == 3 -> "triple tap"
+            else -> taps.toString() + " taps"
+        }
+        return keys.joinToString(" + ") + ", " + what
+    }
+
+    /** "Power, hold: Home" - one line of the "Your buttons" list. */
+    fun binding(gesture: String, action: String): String = gesture + ": " + action
+
+    /** How many gestures are bound, over how many keys. */
+    fun keys(keyCount: Int, boundCount: Int): String {
+        if (boundCount == 0) return "Nothing bound yet"
+        return count(boundCount, "action") + " on " + count(keyCount, "key")
+    }
+
+    /** The Power button, which has its own screen. */
+    fun power(boundCount: Int): String =
+        if (boundCount == 0) "Default behaviour" else count(boundCount, "action") + " bound"
+
+    /** Which ways of getting around are switched on. */
+    fun navigation(buttons: Boolean, gestures: Boolean, keys: Boolean): String {
+        val parts = ArrayList<String>(3)
+        if (buttons) parts.add("Buttons")
+        if (gestures) parts.add("Gestures")
+        if (keys) parts.add("Power key")
+        if (parts.isEmpty()) return "Nothing switched on"
+        return parts.joinToString(" · ")
+    }
+
+    /** Whether voice typing can work, and what is missing if it cannot. */
+    fun voice(microphone: Boolean, speechApps: Int, onDevice: Boolean): String = when {
+        speechApps == 0 -> "Needs a speech app"
+        !microphone -> "Needs the microphone"
+        onDevice -> "Ready, on this device"
+        else -> "Ready"
+    }
+
+    /** The home screen and the recent-apps cards. */
+    fun homeAndRecents(homeOffered: Boolean, isDefault: Boolean): String = when {
+        isDefault -> "Home screen in use"
+        homeOffered -> "Home screen ready"
+        else -> "Recent apps only"
+    }
+
+    /**
+     * Setup shows the blocker first. Key remapping being off stops everything
+     * else, so it is said before any count of permissions.
+     */
+    fun setup(keyRemapping: Boolean, granted: Int, total: Int): String = when {
+        !keyRemapping -> "Key remapping is off"
+        granted >= total -> "All set"
+        else -> (total - granted).toString() + " to set up"
+    }
+
+    /** The licence chip in the app bar. Short, because it sits beside the name. */
+    fun licenceChip(tier: License.Tier, trialDaysLeft: Int): String = when (tier) {
+        License.Tier.LICENSED -> "Unlocked"
+        License.Tier.BETA -> "Beta"
+        License.Tier.TRIAL -> count(trialDaysLeft, "day") + " left"
+        License.Tier.LOCKED -> "Locked"
+    }
+
+    /** The licence line on its own screen row. */
+    fun licence(tier: License.Tier, trialDaysLeft: Int): String = when (tier) {
+        License.Tier.LICENSED -> "Bought. Thank you."
+        License.Tier.BETA -> "Free while the beta is open"
+        License.Tier.TRIAL -> count(trialDaysLeft, "day") + " of the trial left"
+        License.Tier.LOCKED -> "Remapping is off until a licence is bought"
+    }
+
+    /** "1 key", "3 keys". */
+    fun count(n: Int, one: String): String =
+        n.toString() + " " + (if (n == 1) one else one + "s")
+}
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/TimingActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/TimingActivity.kt
index 20db95b..d0ec638 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/TimingActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/TimingActivity.kt
@@ -8,7 +8,6 @@ import dev.equwal.assistkey.store.Store
 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
 
 /**
  * The three windows the gesture engine runs on. Exposed because the right
@@ -23,8 +22,7 @@ class TimingActivity : Activity() {
     }
 
     private fun build() {
-        val col = Ui.page(this)
-        col.title("Gesture timing")
+        val col = Ui.page(this, "Gesture timing")
         val cfg = Store.timing(this)
 
         section(
@@ -71,7 +69,7 @@ class TimingActivity : Activity() {
     ) {
         col.header(title)
         col.note(blurb)
-        col.row(current.toString() + " ms", "Tap to change") {
+        col.row(title, "Tap to change", state = current.toString() + " ms") {
             val labels = options.map { v ->
                 (if (v == current) "* " else "   ") + v + " ms"
             }
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 df35ef8..966d3cd 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/TriggerListActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/TriggerListActivity.kt
@@ -13,9 +13,9 @@ import dev.equwal.assistkey.native.ViwoodsBridge
 import dev.equwal.assistkey.store.Store
 import dev.equwal.assistkey.ui.Ui.check
 import dev.equwal.assistkey.ui.Ui.header
+import dev.equwal.assistkey.ui.Ui.more
 import dev.equwal.assistkey.ui.Ui.note
 import dev.equwal.assistkey.ui.Ui.row
-import dev.equwal.assistkey.ui.Ui.title
 
 /** Every gesture available on one key or chord, with what it currently does. */
 class TriggerListActivity : Activity() {
@@ -35,16 +35,16 @@ class TriggerListActivity : Activity() {
     }
 
     private fun build() {
-        val col = Ui.page(this)
-        col.title(keys.sortedBy { it.ordinal }.joinToString(" + ") { it.label })
+        val col = Ui.page(this, keys.sortedBy { it.ordinal }.joinToString(" + ") { it.label })
 
         if (!Channels.isEnabled(this, Channel.ACCESSIBILITY) ||
             !Channels.isSatisfied(this, Channel.ACCESSIBILITY)
         ) {
-            col.note(
-                "The accessibility key filter is not active, so nothing here " +
-                    "will fire yet. Turn it on from the main screen."
-            )
+            col.row(
+                "Key remapping is off",
+                "Nothing here will fire yet",
+                state = "Off"
+            ) { startActivity(Intent(this, SetupActivity::class.java)) }
         }
 
         keys.filter { ViwoodsBridge.hidesFromFilter(this, it) }.forEach { k ->
@@ -86,12 +86,7 @@ class TriggerListActivity : Activity() {
         val hold = Trigger(keys, GestureType.HOLD)
         col.row("Press and hold", b.raw(hold).describe()) { edit(hold) }
 
-        col.header("Note")
-        col.note(
-            "Binding a double tap adds a short delay to the single tap, " +
-                "because the app has to wait and see. Leave the higher tap " +
-                "counts on default behaviour if you want the key to feel instant."
-        )
+        col.more("Taps and delay", DELAY)
     }
 
     private fun tapLabel(n: Int) = when (n) {
@@ -106,6 +101,13 @@ class TriggerListActivity : Activity() {
     companion object {
         private const val EXTRA_KEYS = "keys"
 
+        private const val DELAY =
+            "Binding a double tap adds a short delay to the single tap, " +
+                "because the app has to wait and see whether a second tap " +
+                "follows. Leave the higher tap counts on default behaviour if " +
+                "you want the key to feel instant.\n\n" +
+                "The wait is set under Advanced > Gesture timing."
+
         fun intent(c: Context, keys: Set<HwKey>): Intent =
             Intent(c, TriggerListActivity::class.java).putExtra(
                 EXTRA_KEYS, keys.sortedBy { it.ordinal }.joinToString("+") { it.token }
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 1610a2f..ee52744 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/Ui.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/Ui.kt
@@ -3,9 +3,16 @@ package dev.equwal.assistkey.ui
 import android.app.Activity
 import android.app.AlertDialog
 import android.content.Context
+import android.graphics.Canvas
 import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Path
 import android.graphics.Typeface
+import android.graphics.drawable.GradientDrawable
+import android.graphics.drawable.LayerDrawable
+import android.graphics.drawable.StateListDrawable
 import android.text.InputType
+import android.text.TextUtils
 import android.util.TypedValue
 import android.view.Gravity
 import android.view.View
@@ -23,14 +30,28 @@ import android.widget.TextView
  * View construction, in code, with no support library.
  *
  * The screen is e-ink: no colour, slow refresh, and a hard time with subtle
- * greys. So everything here is black on white at generous sizes, and nothing
- * animates.
+ * greys. So everything here is black on white at generous sizes, outlines are
+ * real black lines, and nothing animates or ripples.
+ *
+ * The rules this file keeps, so that every screen looks the same:
+ *
+ *  - 16 dp side margins, and spacing on an 8 dp grid.
+ *  - Rows and checks are at least 56 dp high, and the whole row is the
+ *    touch target.
+ *  - Titles are 17 sp, summaries 14 sp, section headers 13 sp small caps.
+ *  - Secondary text is #444444. Nothing lighter carries a fact.
+ *  - Buttons are 48 dp high and outlined. The one primary button on a screen
+ *    is 56 dp high and filled black.
  */
 object Ui {
 
     const val INK = Color.BLACK
-    val DIM = Color.rgb(90, 90, 90)
-    val RULE = Color.rgb(200, 200, 200)
+
+    /** Secondary text. The lightest grey that still reads on e-ink. */
+    val DIM = Color.rgb(68, 68, 68)
+
+    /** Separator lines between rows. */
+    val RULE = Color.rgb(150, 150, 150)
 
     /** A key's name mid-sentence: "volume up", but never "ai key". */
     fun inSentence(key: dev.equwal.assistkey.model.HwKey): String =
@@ -40,34 +61,91 @@ object Ui {
         TypedValue.COMPLEX_UNIT_DIP, v.toFloat(), resources.displayMetrics
     ).toInt()
 
-    /** Root scrolling column; returns the column so callers can keep filling it. */
-    fun page(a: Activity): LinearLayout {
+    private fun Context.px(v: Int): Int = maxOf(1, dp(v))
+
+    // ---- page ------------------------------------------------------------
+
+    /**
+     * Root of a screen; returns the column that callers fill.
+     *
+     * With a [title] the screen gets the standard top bar: a back arrow with a
+     * 48 dp touch target, the title, and a rule under it. The content below
+     * scrolls only when it does not fit.
+     */
+    fun page(a: Activity, title: String? = null): LinearLayout {
+        val root = LinearLayout(a).apply {
+            orientation = LinearLayout.VERTICAL
+            setBackgroundColor(Color.WHITE)
+        }
+        if (title != null) {
+            root.addView(topBar(a, title))
+            root.addView(View(a).apply {
+                setBackgroundColor(RULE)
+                layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, 1)
+            })
+        }
+
         val col = LinearLayout(a).apply {
             orientation = LinearLayout.VERTICAL
-            val p = a.dp(16)
-            setPadding(p, p, p, a.dp(40))
+            setPadding(a.dp(16), a.dp(if (title == null) 8 else 12), a.dp(16), a.dp(24))
         }
         val scroll = ScrollView(a).apply {
             isFillViewport = true
-            setBackgroundColor(Color.WHITE)
-            addView(col, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT))
-            // Edge-to-edge is enforced from Android 15, so the status and
-            // navigation bars overlap the window. Pad by whatever they cover;
-            // the scrolling content still runs underneath.
             clipToPadding = false
-            setOnApplyWindowInsetsListener { v, insets ->
-                val bars = insets.getInsets(
-                    WindowInsets.Type.systemBars() or WindowInsets.Type.displayCutout()
-                )
-                v.setPadding(bars.left, bars.top, bars.right, bars.bottom)
-                insets
-            }
+            addView(col, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT))
         }
-        a.setContentView(scroll)
-        scroll.requestApplyInsets()
+        root.addView(scroll, LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f))
+
+        // Edge-to-edge is enforced from Android 15, so the system bars overlap
+        // the window. The top bar moves down by whatever the status bar covers,
+        // and the scrolling content keeps clear of the navigation bar.
+        root.setOnApplyWindowInsetsListener { v, insets ->
+            val bars = insets.getInsets(
+                WindowInsets.Type.systemBars() or WindowInsets.Type.displayCutout()
+            )
+            v.setPadding(bars.left, bars.top, bars.right, 0)
+            col.setPadding(
+                col.paddingLeft, col.paddingTop, col.paddingRight, a.dp(24) + bars.bottom
+            )
+            insets
+        }
+        a.setContentView(root)
+        root.requestApplyInsets()
         return col
     }
 
+    private fun topBar(a: Activity, text: String): View {
+        val bar = LinearLayout(a).apply {
+            orientation = LinearLayout.HORIZONTAL
+            gravity = Gravity.CENTER_VERTICAL
+            setPadding(a.dp(4), 0, a.dp(16), 0)
+            layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, a.dp(56))
+        }
+        bar.addView(Glyph(a, Glyph.BACK).apply {
+            contentDescription = "Back"
+            isClickable = true
+            isFocusable = true
+            @Suppress("DEPRECATION")
+            setOnClickListener { a.onBackPressed() }
+            layoutParams = LinearLayout.LayoutParams(a.dp(48), a.dp(48))
+        })
+        bar.addView(TextView(a).apply {
+            this.text = text
+            setTextColor(INK)
+            setTextSize(TypedValue.COMPLEX_UNIT_SP, 20f)
+            setTypeface(Typeface.DEFAULT_BOLD)
+            maxLines = 1
+            ellipsize = TextUtils.TruncateAt.END
+            layoutParams = LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f).apply {
+                marginStart = a.dp(4)
+            }
+        })
+        return bar
+    }
+
+    // ---- text ------------------------------------------------------------
+
+    /** Screen title, for a screen that draws no top bar. */
     fun LinearLayout.title(text: String) = add(TextView(context).apply {
         this.text = text
         setTextColor(INK)
@@ -82,13 +160,14 @@ object Ui {
         setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
         setTypeface(Typeface.DEFAULT_BOLD)
         letterSpacing = 0.08f
-        setPadding(0, context.dp(20), 0, context.dp(6))
+        setPadding(0, context.dp(24), 0, context.dp(8))
     })
 
     fun LinearLayout.note(text: String) = add(TextView(context).apply {
         this.text = text
         setTextColor(DIM)
         setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
+        setLineSpacing(context.dp(3).toFloat(), 1f)
         setPadding(0, 0, 0, context.dp(8))
     })
 
@@ -97,43 +176,66 @@ object Ui {
         layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, 1)
     })
 
-    /** A tappable two-line row: what it is, and what it currently does. */
+    // ---- rows ------------------------------------------------------------
+
+    /**
+     * A tappable row: what it is, what it currently does, and either a state
+     * chip or a chevron on the right. The whole row is the touch target.
+     */
     fun LinearLayout.row(
         title: String,
         subtitle: String? = null,
         enabled: Boolean = true,
+        state: String? = null,
         onClick: (() -> Unit)? = null
     ): LinearLayout {
         val box = LinearLayout(context).apply {
-            orientation = LinearLayout.VERTICAL
-            val v = context.dp(12)
+            orientation = LinearLayout.HORIZONTAL
+            gravity = Gravity.CENTER_VERTICAL
+            minimumHeight = context.dp(56)
+            val v = context.dp(10)
             setPadding(0, v, 0, v)
             isClickable = onClick != null && enabled
-            if (isClickable) {
-                setBackgroundResource(
-                    android.R.drawable.list_selector_background
-                )
-                setOnClickListener { onClick?.invoke() }
-            }
+            if (isClickable) setOnClickListener { onClick?.invoke() }
         }
-        box.addView(TextView(context).apply {
+        val texts = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL }
+        texts.addView(TextView(context).apply {
             text = title
             setTextColor(if (enabled) INK else DIM)
             setTextSize(TypedValue.COMPLEX_UNIT_SP, 17f)
         })
         if (!subtitle.isNullOrBlank()) {
-            box.addView(TextView(context).apply {
+            texts.addView(TextView(context).apply {
                 text = subtitle
                 setTextColor(DIM)
                 setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
                 setPadding(0, context.dp(2), 0, 0)
             })
         }
+        box.addView(texts, LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f))
+
+        if (state != null) {
+            box.addView(chip(context, state).apply {
+                layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
+                    marginStart = context.dp(8)
+                }
+            })
+        } else if (box.isClickable) {
+            box.addView(Glyph(context, Glyph.CHEVRON).apply {
+                layoutParams = LinearLayout.LayoutParams(context.dp(24), context.dp(24)).apply {
+                    marginStart = context.dp(8)
+                }
+            })
+        }
         add(box)
         rule()
         return box
     }
 
+    /**
+     * A check whose whole row is the touch target. The box is drawn, not
+     * themed, so it is a real black outline on every firmware.
+     */
     fun LinearLayout.check(
         title: String,
         subtitle: String?,
@@ -141,35 +243,174 @@ object Ui {
         onChange: (Boolean) -> Unit
     ): CheckBox {
         val cb = CheckBox(context).apply {
-            text = title
+            text = ""
             isChecked = checked
+            buttonDrawable = checkBox(context)
+            isClickable = false
+            isFocusable = false
+            minWidth = 0
+            minimumWidth = 0
+            setPadding(0, 0, 0, 0)
+        }
+        val box = LinearLayout(context).apply {
+            orientation = LinearLayout.HORIZONTAL
+            gravity = Gravity.CENTER_VERTICAL
+            minimumHeight = context.dp(56)
+            val v = context.dp(10)
+            setPadding(0, v, 0, v)
+            isClickable = true
+            setOnClickListener {
+                cb.isChecked = !cb.isChecked
+                onChange(cb.isChecked)
+            }
+        }
+        box.addView(cb, LinearLayout.LayoutParams(context.dp(24), context.dp(24)).apply {
+            marginEnd = context.dp(14)
+        })
+        val texts = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL }
+        texts.addView(TextView(context).apply {
+            text = title
             setTextColor(INK)
             setTextSize(TypedValue.COMPLEX_UNIT_SP, 17f)
-            setPadding(context.dp(8), context.dp(10), 0, context.dp(2))
-            setOnClickListener { onChange(isChecked) }
-        }
-        add(cb)
+        })
         if (!subtitle.isNullOrBlank()) {
-            add(TextView(context).apply {
+            texts.addView(TextView(context).apply {
                 text = subtitle
                 setTextColor(DIM)
                 setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
-                setPadding(context.dp(44), 0, 0, context.dp(10))
+                setPadding(0, context.dp(2), 0, 0)
             })
         }
+        box.addView(texts, LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f))
+        add(box)
+        rule()
         return cb
     }
 
-    fun LinearLayout.button(text: String, onClick: () -> Unit) = add(Button(context).apply {
+    /**
+     * Moves a long explanation off the screen and behind one row. Every fact
+     * stays; the screen stops being a wall of text.
+     */
+    fun LinearLayout.more(dialogTitle: String, body: String) {
+        val a = context
+        row("More about this") {
+            AlertDialog.Builder(a)
+                .setTitle(dialogTitle)
+                .setMessage(body)
+                .setPositiveButton(android.R.string.ok, null)
+                .show()
+        }
+    }
+
+    // ---- tiles -----------------------------------------------------------
+
+    /** One hub tile: where it leads, and what state it is in now. */
+    class Tile(val title: String, val summary: String, val onClick: () -> Unit)
+
+    /**
+     * Two columns of outlined tiles. A tile left over at the end takes the
+     * full width, so no row is ever half empty.
+     */
+    fun LinearLayout.tiles(items: List<Tile>) {
+        var i = 0
+        while (i < items.size) {
+            if (items.size - i == 1) {
+                add(tile(context, items[i]).apply {
+                    layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply {
+                        topMargin = context.dp(8)
+                    }
+                })
+                i += 1
+                continue
+            }
+            val pair = LinearLayout(context).apply {
+                orientation = LinearLayout.HORIZONTAL
+                layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply {
+                    topMargin = context.dp(8)
+                }
+            }
+            pair.addView(tile(context, items[i]), LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f).apply {
+                marginEnd = context.dp(8)
+            })
+            pair.addView(tile(context, items[i + 1]), LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f))
+            add(pair)
+            i += 2
+        }
+    }
+
+    private fun tile(c: Context, t: Tile): View {
+        val box = LinearLayout(c).apply {
+            orientation = LinearLayout.VERTICAL
+            background = outline(c)
+            minimumHeight = c.dp(84)
+            val p = c.dp(14)
+            setPadding(p, p, p, p)
+            isClickable = true
+            isFocusable = true
+            setOnClickListener { t.onClick() }
+        }
+        box.addView(TextView(c).apply {
+            text = t.title
+            setTextColor(INK)
+            setTextSize(TypedValue.COMPLEX_UNIT_SP, 17f)
+            setTypeface(Typeface.DEFAULT_BOLD)
+        })
+        box.addView(TextView(c).apply {
+            text = t.summary
+            setTextColor(DIM)
+            setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
+            maxLines = 2
+            ellipsize = TextUtils.TruncateAt.END
+            setPadding(0, c.dp(4), 0, 0)
+        })
+        return box
+    }
+
+    // ---- buttons ---------------------------------------------------------
+
+    /** The one action a screen is really for: filled black, 56 dp, full width. */
+    fun LinearLayout.primaryButton(text: String, onClick: () -> Unit) =
+        add(flatButton(context, text, 56, Color.WHITE, INK, 17f).apply {
+            setOnClickListener { onClick() }
+        })
+
+    /** Every other action: outlined, 48 dp, full width. */
+    fun LinearLayout.button(text: String, onClick: () -> Unit) =
+        add(flatButton(context, text, 48, INK, Color.WHITE, 16f).apply {
+            setOnClickListener { onClick() }
+        })
+
+    /**
+     * A button with the platform look taken off: our own outline or fill, no
+     * elevation, no ripple - which smears on an e-ink panel.
+     */
+    private fun flatButton(
+        c: Context,
+        text: String,
+        height: Int,
+        textColor: Int,
+        fill: Int,
+        size: Float
+    ): Button = Button(c).apply {
         this.text = text
-        setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f)
-        setOnClickListener { onClick() }
-        layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
-            topMargin = context.dp(4)
-            bottomMargin = context.dp(8)
-            gravity = Gravity.START
+        isAllCaps = false
+        gravity = Gravity.CENTER
+        setTextColor(textColor)
+        setTextSize(TypedValue.COMPLEX_UNIT_SP, size)
+        setTypeface(Typeface.DEFAULT_BOLD)
+        background = outline(c, fill)
+        stateListAnimator = null
+        elevation = 0f
+        minWidth = 0
+        minimumWidth = 0
+        minHeight = 0
+        minimumHeight = 0
+        setPadding(c.dp(16), 0, c.dp(16), 0)
+        layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, c.dp(height)).apply {
+            topMargin = c.dp(8)
+            bottomMargin = c.dp(8)
         }
-    })
+    }
 
     /** Monospace block for adb commands the user has to run themselves. */
     fun LinearLayout.code(text: String) = add(TextView(context).apply {
@@ -177,15 +418,112 @@ object Ui {
         setTextColor(INK)
         setTypeface(Typeface.MONOSPACE)
         setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
-        setBackgroundColor(Color.rgb(240, 240, 240))
-        val p = context.dp(8)
+        background = outline(context)
+        val p = context.dp(10)
         setPadding(p, p, p, p)
         setTextIsSelectable(true)
         layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply {
-            bottomMargin = context.dp(10)
+            topMargin = context.dp(4)
+            bottomMargin = context.dp(12)
         }
     })
 
+    // ---- drawing -----------------------------------------------------------
+
+    /** A black outline on a fill: the one shape this app is built from. */
+    fun outline(c: Context, fill: Int = Color.WHITE, radius: Int = 2): GradientDrawable =
+        GradientDrawable().apply {
+            shape = GradientDrawable.RECTANGLE
+            setColor(fill)
+            cornerRadius = c.dp(radius).toFloat()
+            setStroke(c.px(1), INK)
+        }
+
+    /** A short state word - On, Off, Granted, Missing - in an outlined box. */
+    fun chip(c: Context, text: String): TextView = TextView(c).apply {
+        this.text = text
+        setTextColor(INK)
+        setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
+        setTypeface(Typeface.DEFAULT_BOLD)
+        background = outline(c, radius = 10)
+        setPadding(c.dp(8), c.dp(3), c.dp(8), c.dp(3))
+        maxLines = 1
+    }
+
+    /** Empty square, or square with a solid black block in it. */
+    private fun checkBox(c: Context): StateListDrawable {
+        val size = c.dp(24)
+        val empty = GradientDrawable().apply {
+            shape = GradientDrawable.RECTANGLE
+            setColor(Color.WHITE)
+            cornerRadius = c.dp(2).toFloat()
+            setStroke(c.px(2), INK)
+            setSize(size, size)
+        }
+        val frame = GradientDrawable().apply {
+            shape = GradientDrawable.RECTANGLE
+            setColor(Color.WHITE)
+            cornerRadius = c.dp(2).toFloat()
+            setStroke(c.px(2), INK)
+            setSize(size, size)
+        }
+        val block = GradientDrawable().apply {
+            shape = GradientDrawable.RECTANGLE
+            setColor(INK)
+        }
+        val ticked = LayerDrawable(arrayOf(frame, block)).apply {
+            val inset = c.dp(6)
+            setLayerInset(1, inset, inset, inset, inset)
+        }
+        return StateListDrawable().apply {
+            addState(intArrayOf(android.R.attr.state_checked), ticked)
+            addState(intArrayOf(), empty)
+        }
+    }
+
+    /**
+     * The back arrow and the row chevron, drawn rather than typed, because a
+     * firmware font may not carry the characters.
+     */
+    private class Glyph(c: Context, private val kind: Int) : View(c) {
+
+        private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+            color = INK
+            style = Paint.Style.STROKE
+            strokeWidth = c.resources.displayMetrics.density * 2f
+            strokeCap = Paint.Cap.ROUND
+            strokeJoin = Paint.Join.ROUND
+        }
+        private val path = Path()
+
+        override fun onDraw(canvas: Canvas) {
+            val cx = width / 2f
+            val cy = height / 2f
+            path.reset()
+            if (kind == BACK) {
+                val r = minOf(width, height) * 0.24f
+                val h = r * 0.62f
+                path.moveTo(cx + r, cy)
+                path.lineTo(cx - r, cy)
+                path.moveTo(cx - r + h, cy - h)
+                path.lineTo(cx - r, cy)
+                path.lineTo(cx - r + h, cy + h)
+            } else {
+                val w = minOf(width, height) * 0.17f
+                val h = w * 1.7f
+                path.moveTo(cx - w, cy - h)
+                path.lineTo(cx + w, cy)
+                path.lineTo(cx - w, cy + h)
+            }
+            canvas.drawPath(path, paint)
+        }
+
+        companion object {
+            const val BACK = 0
+            const val CHEVRON = 1
+        }
+    }
+
     /** Full-width by default; callers that want otherwise set params first. */
     private fun LinearLayout.add(v: View) {
         if (v.layoutParams == null) {
@@ -194,6 +532,8 @@ object Ui {
         addView(v)
     }
 
+    // ---- dialogs -----------------------------------------------------------
+
     fun pick(a: Activity, title: String, labels: List<String>, onPick: (Int) -> Unit) {
         AlertDialog.Builder(a)
             .setTitle(title)
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 bf18155..ef0cc54 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/ViwoodsActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/ViwoodsActivity.kt
@@ -8,7 +8,6 @@ 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
 
 /**
  * Shows what the firmware itself has bound to each key.
@@ -26,8 +25,7 @@ class ViwoodsActivity : Activity() {
     }
 
     private fun build() {
-        val col = Ui.page(this)
-        col.title("Firmware key hooks")
+        val col = Ui.page(this, "Firmware key hooks")
         col.note(
             "The firmware has its own setting for each key. Android does not allow " +
                 "apps to change these, so they are shown here for information, " +
@@ -38,7 +36,12 @@ class ViwoodsActivity : Activity() {
 
     private fun keyBlock(col: LinearLayout, key: HwKey) {
         col.header(key.label)
-        col.row("Firmware setting: " + ViwoodsBridge.describe(this, key), null, enabled = false)
+        col.row(
+            "Firmware setting",
+            ViwoodsBridge.describe(this, key),
+            enabled = false,
+            state = if (ViwoodsBridge.hidesFromFilter(this, key)) "Hiding" else "Clear"
+        )
 
         if (key == HwKey.AI) {
             if (ViwoodsBridge.aiHookedToUs(this)) {
diff --git a/app/src/main/java/dev/equwal/assistkey/ui/VoiceActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/VoiceActivity.kt
index aed55d9..2436b88 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/VoiceActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/VoiceActivity.kt
@@ -10,9 +10,9 @@ import dev.equwal.assistkey.channel.Channels
 import dev.equwal.assistkey.voice.Dictation
 import dev.equwal.assistkey.ui.Ui.button
 import dev.equwal.assistkey.ui.Ui.header
+import dev.equwal.assistkey.ui.Ui.more
 import dev.equwal.assistkey.ui.Ui.note
 import dev.equwal.assistkey.ui.Ui.row
-import dev.equwal.assistkey.ui.Ui.title
 
 /** Setup for the Voice typing action: microphone, speech app, language. */
 class VoiceActivity : Activity() {
@@ -28,21 +28,13 @@ class VoiceActivity : Activity() {
     }
 
     private fun build() {
-        val col = Ui.page(this)
-        col.title("Voice typing")
-        col.note(
-            "Bind the Voice typing action to a key. Press the key, speak, and the " +
-                "words go where the cursor is. Press the key again to stop early."
-        )
-        col.note(
-            "AssistKey has no speech engine and no internet access. A speech " +
-                "recognition app on this device does the listening, and AssistKey " +
-                "only receives the text."
-        )
+        val col = Ui.page(this, "Voice typing")
+        col.note("Press a key, speak, and the words go where the cursor is.")
+        col.more("Voice typing", ABOUT)
 
         col.header("1. Microphone")
         if (Dictation.hasMicrophone(this)) {
-            col.row("Allowed", null, enabled = false)
+            col.row("Microphone", null, enabled = false, state = "Allowed")
         } else {
             col.button("Allow the microphone") {
                 requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), 1)
@@ -61,9 +53,9 @@ class VoiceActivity : Activity() {
         val engines = Dictation.engines(this)
         val current = Dictation.engine(this)
         engines.forEach { e ->
-            val mark = if (e.component == current) "* " else "   "
-            val where = if (Dictation.isOnDevice(e)) "Recognises on this device" else "May send speech to its own server"
-            col.row(mark + e.title, where) {
+            val where = if (Dictation.isOnDevice(e)) "Recognises on this device"
+            else "May send speech to its own server"
+            col.row(e.title, where, state = if (e.component == current) "In use" else null) {
                 Dictation.setEngine(this, e.component)
                 build()
             }
@@ -71,8 +63,7 @@ class VoiceActivity : Activity() {
         if (engines.none(Dictation::isOnDevice)) {
             col.note(
                 "No app on this device recognises speech offline. Whisper, from " +
-                    "F-Droid, is free, works with no connection after its model is " +
-                    "downloaded, and detects the language by itself."
+                    "F-Droid, is free and works with no connection."
             )
             col.button("Get Whisper") {
                 val pages = listOf(
@@ -91,21 +82,37 @@ class VoiceActivity : Activity() {
 
         col.header("3. Language")
         val lang = Dictation.language(this)
-        col.note(
-            "Leave this empty to let the speech app detect the language. Whisper " +
-                "detects it from what you say, so you can change language from one " +
-                "sentence to the next."
-        )
-        col.row(if (lang.isEmpty()) "Detect the language" else lang, "Or force one: a tag such as en-US or de-DE") {
+        col.row(
+            if (lang.isEmpty()) "Detect the language" else lang,
+            "Or force one: a tag such as en-US or de-DE"
+        ) {
             Ui.textInput(this, "Language tag", "en-US", lang) { Dictation.setLanguage(this, it); build() }
         }
         if (lang.isNotEmpty()) col.button("Clear the language") { Dictation.setLanguage(this, ""); build() }
 
         col.header("4. Key")
-        col.note("Open a key on the main screen, choose a gesture, then Typing > Voice typing.")
+        col.note("Set up a button, choose a gesture, then Typing > Voice typing.")
         if (!Channels.isSatisfied(this, Channel.ACCESSIBILITY)) {
-            col.note("Key remapping is off. Voice typing needs it to reach the text field.")
+            col.row(
+                "Key remapping is off",
+                "Voice typing needs it to reach the text field",
+                state = "Off"
+            ) { startActivity(Intent(this, SetupActivity::class.java)) }
         }
         col.note("Voice typing never writes into password fields.")
     }
+
+    private companion object {
+        const val ABOUT =
+            "Bind the Voice typing action to a key. Press the key, speak, and " +
+                "the words go where the cursor is. Press the key again to stop " +
+                "early.\n\n" +
+                "AssistKey has no speech engine and no internet access. A " +
+                "speech recognition app on this device does the listening, and " +
+                "AssistKey only receives the text.\n\n" +
+                "Leave the language empty to let the speech app detect it. " +
+                "Whisper detects it from what you say, so you can change " +
+                "language from one sentence to the next.\n\n" +
+                "Voice typing never writes into a password field."
+    }
 }
diff --git a/app/src/test/java/dev/equwal/assistkey/setup/RouteTest.kt b/app/src/test/java/dev/equwal/assistkey/setup/RouteTest.kt
new file mode 100644
index 0000000..a5b8601
--- /dev/null
+++ b/app/src/test/java/dev/equwal/assistkey/setup/RouteTest.kt
@@ -0,0 +1,67 @@
+package dev.equwal.assistkey.setup
+
+import dev.equwal.assistkey.model.GestureType
+import dev.equwal.assistkey.model.HwKey
+import dev.equwal.assistkey.model.Trigger
+import dev.equwal.assistkey.setup.Route.Need
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class RouteTest {
+
+    private val sideDoors = Route.Env(powerIsDirect = false)
+    private val direct = Route.Env(powerIsDirect = true)
+    private val power = setOf(HwKey.POWER)
+
+    @Test fun `a volume key needs the key filter and nothing more`() {
+        val t = Trigger(setOf(HwKey.VOL_DOWN), GestureType.TAP, 2)
+        assertEquals(listOf(Need.KEY_FILTER), Route.plan(t, true, sideDoors).needs)
+        assertEquals(listOf(Need.KEY_FILTER), Route.plan(t, false, sideDoors).needs)
+    }
+
+    @Test fun `Power hold goes through the assistant`() {
+        val t = Trigger(power, GestureType.HOLD)
+        assertEquals(listOf(Need.ASSISTANT), Route.plan(t, false, sideDoors).needs)
+        assertEquals(listOf(Need.KEY_FILTER, Need.ASSISTANT), Route.plan(t, true, sideDoors).needs)
+    }
+
+    @Test fun `Power double press goes through the camera`() {
+        val t = Trigger(power, GestureType.TAP, 2)
+        assertEquals(listOf(Need.CAMERA), Route.plan(t, false, sideDoors).needs)
+        assertEquals(listOf(Need.KEY_FILTER, Need.CAMERA), Route.plan(t, true, sideDoors).needs)
+    }
+
+    @Test fun `Power tap and triple tap cannot work with the side doors, and the plan says why`() {
+        listOf(1, 3, 4, 5).forEach { n ->
+            val plan = Route.plan(Trigger(power, GestureType.TAP, n), true, sideDoors)
+            assertFalse(plan.possible)
+            assertTrue(plan.needs.isEmpty())
+            assertTrue(plan.blocked!!.contains("Hold and double press"))
+        }
+    }
+
+    @Test fun `Power held then another button needs the filter, because the filter sees the second button`() {
+        val plan = Route.plan(Trigger.powerThen(HwKey.VOL_UP), false, sideDoors)
+        assertEquals(listOf(Need.KEY_FILTER, Need.ASSISTANT), plan.needs)
+    }
+
+    @Test fun `with direct Power access every Power gesture works through the key filter`() {
+        listOf(
+            Trigger(power, GestureType.TAP, 1),
+            Trigger(power, GestureType.TAP, 5),
+            Trigger(power, GestureType.HOLD),
+            Trigger.powerThen(HwKey.VOL_DOWN)
+        ).forEach { assertEquals(listOf(Need.KEY_FILTER), Route.plan(it, true, direct).needs) }
+    }
+
+    @Test fun `the gestures offered are the ones that can work`() {
+        assertEquals(
+            listOf(Trigger(power, GestureType.TAP, 2), Trigger(power, GestureType.HOLD)),
+            Route.gestures(power, sideDoors)
+        )
+        assertEquals(3, Route.gestures(power, direct).size)
+        assertEquals(3, Route.gestures(setOf(HwKey.VOL_UP), sideDoors).size)
+    }
+}
diff --git a/app/src/test/java/dev/equwal/assistkey/ui/SummaryTest.kt b/app/src/test/java/dev/equwal/assistkey/ui/SummaryTest.kt
new file mode 100644
index 0000000..e167404
--- /dev/null
+++ b/app/src/test/java/dev/equwal/assistkey/ui/SummaryTest.kt
@@ -0,0 +1,108 @@
+package dev.equwal.assistkey.ui
+
+import dev.equwal.assistkey.license.License
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/** The hub's one-line state text, which is the only logic in the new screens. */
+class SummaryTest {
+
+    @Test
+    fun `gesture names the key and what you do to it`() {
+        assertEquals("Power, hold", Summary.gesture(listOf("Power"), hold = true, taps = 1))
+        assertEquals("AI key, tap", Summary.gesture(listOf("AI key"), hold = false, taps = 1))
+        assertEquals("AI key, double tap", Summary.gesture(listOf("AI key"), hold = false, taps = 2))
+        assertEquals("AI key, triple tap", Summary.gesture(listOf("AI key"), hold = false, taps = 3))
+        assertEquals("AI key, 5 taps", Summary.gesture(listOf("AI key"), hold = false, taps = 5))
+    }
+
+    @Test
+    fun `gesture joins a combination with a plus`() {
+        assertEquals(
+            "Volume up + Volume down, tap",
+            Summary.gesture(listOf("Volume up", "Volume down"), hold = false, taps = 1)
+        )
+    }
+
+    @Test
+    fun `a hold never counts taps`() {
+        assertEquals("Power, hold", Summary.gesture(listOf("Power"), hold = true, taps = 3))
+    }
+
+    @Test
+    fun `binding reads as gesture then action`() {
+        assertEquals("Power, hold: Home", Summary.binding("Power, hold", "Home"))
+    }
+
+    @Test
+    fun `keys counts the bindings, not the keys`() {
+        assertEquals("Nothing bound yet", Summary.keys(3, 0))
+        assertEquals("1 action on 1 key", Summary.keys(1, 1))
+        assertEquals("5 actions on 3 keys", Summary.keys(3, 5))
+    }
+
+    @Test
+    fun `power falls back to the firmware wording`() {
+        assertEquals("Default behaviour", Summary.power(0))
+        assertEquals("1 action bound", Summary.power(1))
+        assertEquals("4 actions bound", Summary.power(4))
+    }
+
+    @Test
+    fun `navigation lists only what is on`() {
+        assertEquals("Nothing switched on", Summary.navigation(false, false, false))
+        assertEquals("Buttons", Summary.navigation(true, false, false))
+        assertEquals("Power key", Summary.navigation(false, false, true))
+        assertEquals("Buttons · Gestures · Power key", Summary.navigation(true, true, true))
+    }
+
+    @Test
+    fun `voice names the first thing that is missing`() {
+        assertEquals("Needs a speech app", Summary.voice(microphone = true, speechApps = 0, onDevice = false))
+        assertEquals("Needs a speech app", Summary.voice(microphone = false, speechApps = 0, onDevice = false))
+        assertEquals("Needs the microphone", Summary.voice(microphone = false, speechApps = 2, onDevice = true))
+        assertEquals("Ready, on this device", Summary.voice(microphone = true, speechApps = 1, onDevice = true))
+        assertEquals("Ready", Summary.voice(microphone = true, speechApps = 1, onDevice = false))
+    }
+
+    @Test
+    fun `home and recents says what is in use`() {
+        assertEquals("Home screen in use", Summary.homeAndRecents(homeOffered = true, isDefault = true))
+        assertEquals("Home screen ready", Summary.homeAndRecents(homeOffered = true, isDefault = false))
+        assertEquals("Recent apps only", Summary.homeAndRecents(homeOffered = false, isDefault = false))
+    }
+
+    @Test
+    fun `setup says the blocker before any count`() {
+        assertEquals("Key remapping is off", Summary.setup(false, 4, 4))
+        assertEquals("All set", Summary.setup(true, 4, 4))
+        assertEquals("2 to set up", Summary.setup(true, 2, 4))
+    }
+
+    @Test
+    fun `the licence chip stays short`() {
+        assertEquals("Unlocked", Summary.licenceChip(License.Tier.LICENSED, 0))
+        assertEquals("Beta", Summary.licenceChip(License.Tier.BETA, 0))
+        assertEquals("1 day left", Summary.licenceChip(License.Tier.TRIAL, 1))
+        assertEquals("7 days left", Summary.licenceChip(License.Tier.TRIAL, 7))
+        assertEquals("Locked", Summary.licenceChip(License.Tier.LOCKED, 0))
+    }
+
+    @Test
+    fun `the licence line explains the tier`() {
+        assertEquals("Bought. Thank you.", Summary.licence(License.Tier.LICENSED, 0))
+        assertEquals("Free while the beta is open", Summary.licence(License.Tier.BETA, 0))
+        assertEquals("3 days of the trial left", Summary.licence(License.Tier.TRIAL, 3))
+        assertEquals(
+            "Remapping is off until a licence is bought",
+            Summary.licence(License.Tier.LOCKED, 0)
+        )
+    }
+
+    @Test
+    fun `count makes the plural`() {
+        assertEquals("0 keys", Summary.count(0, "key"))
+        assertEquals("1 key", Summary.count(1, "key"))
+        assertEquals("2 keys", Summary.count(2, "key"))
+    }
+}
diff --git a/gradle.properties b/gradle.properties
index 608e888..af5c4c1 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.7-alpha
-assistkey.versionCode=7
+assistkey.versionName=0.0.8-alpha
+assistkey.versionCode=8
 
 # 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 40c8165..9349378 100644
--- a/play/LISTING.md
+++ b/play/LISTING.md
@@ -28,6 +28,8 @@ AssistKey remaps your device's hardware keys: volume keys, the power button, pag
 
 Turn pages with the volume keys. Hold the power button for Home, double press it for Recents. Put three different actions on one key with a tap, a double tap and a hold.
 
+Setup is three taps: tap the button in a drawing of your device, choose tap, double tap or hold, choose what it does. AssistKey asks only for what that needs.
+
 Built for e-ink readers, and it works on any Android 12+ phone or tablet.
 
 WHAT EACH KEY CAN DO