README.md (23076 bytes)
1 # Rebind 2 3 Formerly AssistKey. The package id, the repository and the code keep the old name. 4 5 Remaps the hardware keys of an Android device - volume keys, the Power button, 6 and whatever else the device has - to arbitrary actions, with multi-tap, 7 press-and-hold and key combinations. Also: navigation in any mix of button bar, 8 gestures and the Power key; a frontlight level below the system's floor; and a 9 recent-apps list made for e-ink. 10 11 It runs on any Android 12+ device. The **Viwoods AiPaper Reader** is the first 12 device profile, because that is where it was built; see *Device profiles*. 13 14 Open source under [GPL-3.0-or-later](LICENSE). Three builds: 15 16 - `play`: on Google Play, as a free download with a one-time licence. Publishing, 17 products, pricing and how the beta is run and ended are in 18 [play/CHECKLIST.md](play/CHECKLIST.md). 19 - `full`: the APK for direct install. It carries the apps of other makers and 20 adds shell access through Shizuku. 21 - `fdroid`: on F-Droid. Free and complete, with no licence check. See 22 [fdroid/](fdroid/). 23 24 The app holds no `INTERNET` permission. 25 26 ## Screenshots 27 28 <p> 29 <img src="docs/screenshots/main.png" width="200" alt="Main screen: a drawing of the device, and each button shows its actions"> 30 <img src="docs/screenshots/setup.png" width="200" alt="Set up a button: tap it in the drawing"> 31 <img src="docs/screenshots/hacks.png" width="200" alt="Hardware hacks"> 32 <img src="docs/screenshots/apps.png" width="200" alt="Apps of other makers that the build carries"> 33 </p> 34 35 The pictures are from a Viwoods AiPaper Reader. 36 37 ## Set up a button 38 39 The main screen is a drawing of the device. Each button shows what it does 40 now. Tap a button, or two buttons for a combination. Choose how you press it. 41 Choose what it does. The app finds what Android must allow for that, and asks 42 for only that. The app does not ask you to press a key to find it. 43 44 The on-screen button is a small round button that floats over every app. It 45 is there only while it has an action. Drag it to move it. 46 47 The same bindings are under **Advanced > Full control**, with every button, 48 every gesture, the Power button and two-button combinations. 49 50 ## Two builds 51 52 One app, one id, one signature, two flavours: 53 54 | Flavour | For | Shizuku | 55 |---|---|---| 56 | `play` | Google Play. `./gradlew bundlePlayRelease` | No code, no permission | 57 | `full` | Direct install. `./gradlew assembleFullRelease` | Yes | 58 59 Shizuku is on Google Play and so are apps that use it, so Play does not forbid 60 it. The `play` flavour leaves it out anyway: an app that holds an accessibility 61 service and a shell has a harder review, and the store build must be the safe 62 one. `shell/Shell.kt` exists once per flavour with the same surface; the `play` 63 one always answers "not available", and every feature falls back by itself. 64 `Shell.SUPPORTED` hides the rows that would lead nowhere. 65 66 ## Shell access 67 68 *`full` flavour only.* 69 70 Android keeps several things from every installed app: the Power key, the 71 navigation bar, system gestures, a maker's own key settings, the backlight 72 node. The shell user can reach them. [Shizuku](https://shizuku.rikka.app/) 73 (MIT API, free app) gives the app that user from the device itself, through 74 wireless debugging - no computer, no root. *Setup > Shell access* walks through 75 it. Everything that depends on it degrades honestly without it. 76 77 | With shell access | Without | 78 |---|---| 79 | Power: any taps, hold, true combinations, read from `/dev/input` | Hold (assistant role) and double press (default camera) only | 80 | Bar and gestures switched in-app | The adb commands are shown | 81 | Recent apps: the real task list, apps can be closed | Rebuilt from the usage log | 82 | Extra-dim light (needs root-level shell) | Not available | 83 84 Implementation notes that cost time: 85 86 - Shizuku's bound *user service* cannot be used: its starter dies inside 87 `LoadedApk.makeApplication` on Android 16 before our class loads. `Shell` 88 uses Shizuku's remote-process call, and the key watcher is a pipe from 89 `getevent -q <node>` on the nodes that declare the key. 90 - While Power is managed, `power_button_short_press`, `power_button_long_press` 91 and the double-tap camera gesture are set to nothing; the firmware values are 92 saved first and restored whenever shell access, the licence, the bindings or 93 the service stop justifying it. Restoring needs no shell: the app grants 94 itself `WRITE_SECURE_SETTINGS` through the shell on first contact. The press 95 that wakes the screen is never a gesture; Power + Volume up stays the power 96 menu. 97 98 ## Device profiles 99 100 `device/Device.kt`. A profile adds only what a maker did differently: extra 101 keys, its own key settings, a gesture switch, a brightness floor. Unknown 102 devices get the generic profile plus any supported key the key filter has 103 actually seen (a page-turn button appears the first time it is pressed). 104 105 New profiles come from **device reports** (*Advanced > Device report*): model, 106 firmware, input devices and the keys they declare, navigation overlays, a 107 whitelist of key and navigation settings, the light's range. The user sees 108 every line and sends it themselves by email or share sheet. There is no 109 automatic telemetry, because there is no `INTERNET` permission; adding one is a 110 deliberate product decision, not a code change. 111 112 ## Extra-dim light 113 114 On the Viwoods reader the frontlight is a backlight LED whose driver accepts 115 1-255 (2047 real steps), but the framework snaps anything under 5 to zero, by 116 every official route including `cmd display set-brightness`. Writing 117 `/sys/class/leds/lcd-backlight/brightness` directly gets under the floor and 118 sticks until the system slider is moved. The node belongs to `system`, so it 119 needs Shizuku running as root or a shell that may `su` (userdebug firmware). 120 121 ## Menus, and export and import 122 123 `menu/`. The action *Menu of actions* binds a gesture to a menu with any number 124 of actions. The menu is stored in the payload of its own binding, as a JSON 125 array of action specs. There is no second store. A menu cannot hold a menu. 126 `MenuActivity` closes before it runs the chosen action, because Back, a swipe 127 or voice typing act on the app in front. 128 129 `store/SettingsFile.kt` turns the settings into one JSON document and back. It 130 has no Android types, so tests run it with no device. `SettingsFile.ALLOWED` is 131 the only list of what may pass, in both directions. The licence, the firmware 132 values that `PowerControl` saved, and facts about one device are not in it. 133 134 ## Tests 135 136 ```bash 137 ./gradlew testPlayReleaseUnitTest # logic, on the computer 138 tools/e2e/power-wake.sh # on a device, through the kernel input node 139 tools/e2e/ai-key-return.sh 140 ``` 141 142 ## Voice typing 143 144 `voice/`. The action *Typing > Voice typing* turns a key into a dictation key. 145 The app has no speech engine and no network access. It calls the Android 146 `SpeechRecognizer` interface, so a speech recognition app on the device does 147 the listening: the system one, an offline Whisper app, or any other app that 148 offers a `RecognitionService`. `TextInsert` then puts the words into the field 149 that has input focus, through the accessibility service: `ACTION_SET_TEXT` at 150 the selection, with a clipboard paste as the fallback. It never writes into a 151 password field. 152 153 The app does not bundle a Whisper model. A multilingual model is 140 MB or 154 more, and with no `INTERNET` permission the app could not download one. The 155 F-Droid app Whisper (`org.woheller69.whisper`) already does this work and 156 offers a `RecognitionService`, so voice typing uses it. `Dictation.pick` chooses 157 an on-device speech app before one that may use a server. With no language tag 158 set, the speech app detects the language. 159 160 Two facts cost time: 161 162 - Android gives the microphone to the app in front only, and an accessibility 163 service does not count (`RECORD_AUDIO` app-op mode is `foreground`). The 164 listening therefore runs in `DictationActivity`, which is see-through and 165 cannot take focus or touches. 166 - The speech recognition app needs its own microphone permission too. If it has 167 none, the framework answers `ERROR_INSUFFICIENT_PERMISSIONS` and blames the 168 caller. 169 170 The Viwoods voice prompt is not reusable. It uploads audio to the Viwoods cloud 171 (`/api/v1/openAi/speechToTextGemini`) and offers no interface to other apps. 172 173 Debug builds carry `FakeRecognitionService`, which hears a fixed sentence, so 174 the whole chain can be tested on a bench with no voice. 175 176 ## Home, recent apps, speech: apps of other makers 177 178 Rebind has no recent-apps screen of its own. That screen is a program of its 179 own, open source: [Ink Recents](https://github.com/equwal/ink-recents). The 180 Recent apps action opens it (`dev.equwal.inkrecents.OPEN`). Where it is not 181 installed, the action opens the screen that offers it. `home/RecentsActivity` 182 is only that stand-in, and keeps the class name that old bindings hold. 183 184 Rebind has no home screen of its own. The `full` build carries the release 185 APKs of other makers, not changed, in `app/src/full/assets/bundled/`, and 186 installs them with `REQUEST_INSTALL_PACKAGES`: inkOS and ThinkLauncher (home 187 screens for e-ink, GPL-3.0), Ink Recents (GPL-3.0) and Whisper (speech to 188 text, MIT). 189 `NOTICE.txt` in that folder names each maker, licence, source and SHA-256. 190 Android asks the user before each install. The `play` build carries nothing 191 and has no install permission. It opens the page of the maker. 192 193 ## Why it is shaped like this 194 195 Android does not offer one way to intercept hardware keys. It offers several 196 partial ones, each with different reach, and the power button has none at all. 197 So the app is organised around **capture channels**: independent routes by which 198 a key press can be made to arrive at this app. You tick the ones you want. 199 200 | Channel | Captures | How | Needs | 201 |---|---|---|---| 202 | **Accessibility key filter** | AI key, Volume up, Volume down | `AccessibilityService.onKeyEvent` with `flagRequestFilterKeyEvents` | Service enabled in Settings | 203 | **Digital assistant** | Power — press and hold | Holds `ROLE_ASSISTANT`; the firmware fires `ACTION_ASSIST` at the role holder | Role granted; firmware long-press set to Assistant | 204 | **Camera app** | Power — double press | Becomes the default camera, so the double-press camera gesture lands here | Set as default camera app | 205 | **Wallet app** | Wallet tile, lock-screen wallet button; double-press Power on firmware that targets the wallet | Holds `ROLE_WALLET` and serves an (empty) `QuickAccessWalletService` | Role granted | 206 207 ### What was measured, and how 208 209 `adb shell input keyevent` is useless for this: injected events skip the input 210 filter stage, so they say nothing about what a real press does. Everything 211 below was measured as root with `sendevent` on the kernel input nodes, which 212 enters the pipeline exactly where the hardware does. 213 214 | Press | Node | Result | 215 |---|---|---| 216 | AI key (`KEY_F1`), stock or unset hook | `event5` "AI KEY" | Reaches the filter, **and the firmware opens its AI screen anyway**, consumed or not | 217 | AI key, hook set to anything else | `event5` | Firmware launches that component; the filter never sees the key | 218 | Power held, then another key | `event1` + key | The second key reaches the filter; no screenshot or power-menu chord fires | 219 | Volume up / down | `event1` / `event2` | Reaches the filter **only while its firmware hook is unset** | 220 | Power, held 700 ms | `event1` | Firmware fires `ACTION_ASSIST` at the assistant role holder — us | 221 | Power, twice within 300 ms | `event1` | `GestureLauncherService` fires `STILL_IMAGE_CAMERA`; never the wallet | 222 223 ### Firmware hooks can hide a key 224 225 The firmware has its own per-key settings in `Settings.System`: 226 `CustomAiKey`, `CustomVolumeUpKey`, `CustomVolumeDownKey`. While a volume hook 227 holds **any** value — even its default token, `volume_up` — the firmware deals 228 with that key before the filter stage and no app ever sees it. Unset, the key 229 arrives normally. The device's own key-settings screen sets them. 230 231 The AI key is worse. With the stock hook the filter does see it, but the 232 firmware opens its AI screen on every press regardless, so a binding fires on 233 top of that screen. The only clean route is to point the hook at this app's 234 `AiKeyActivity`: every press then arrives as a launch, which is enough to count 235 taps (but not to see a release, so no hold and no volume combinations): 236 237 ```bash 238 tools/ai-key hook # or: unhook, status 239 ``` 240 241 An ordinary app cannot write these. `SettingsProvider` rejects any 242 `Settings.System` name outside its public list unless the caller is a 243 privileged system app, with or without `WRITE_SECURE_SETTINGS`: 244 *"You cannot keep your settings in the secure settings."* So 245 *Advanced → Firmware key hooks* is read-only: it shows each hook, says when one 246 is hiding a key, and gives the fix: 247 248 ```bash 249 adb shell settings delete system CustomVolumeUpKey 250 adb shell settings delete system CustomVolumeDownKey 251 ``` 252 253 ### The power button is not like the others 254 255 `PhoneWindowManager.interceptKeyBeforeQueueing()` consumes `KEYCODE_POWER` 256 before the input dispatcher runs, so **no** accessibility service, on any 257 Android version, can see it. That leaves exactly three reachable slots: 258 259 - **Short press** — firmware only. The app rewrites 260 `Settings.Global.power_button_short_press`, so it can become Home, or nothing, 261 instead of sleep. No app code runs. 262 - **Double press** — arrives as a camera launch. With more than one camera app 263 installed Android shows a chooser the first time; pick AssistKey and 264 *Always*. 265 - **Press and hold** — arrives as an assistant request. 266 267 That is the position **without shell access**. With it, none of this section 268 applies - see *Shell access*. Without it there is no multi-tap beyond two, and 269 Power cannot take part in an ordinary combination, but there is one way in: a held Power announces itself, because the firmware 270 fires the assistant at us, and a key pressed while it is still down reaches the 271 filter. So **hold Power, then press** the AI key, Volume up or Volume down is 272 three real combinations. While any is bound, the plain hold action waits one 273 second to see whether a key follows. 274 `Power + Volume up` is reserved by the firmware for the power menu, which the 275 app deliberately leaves alone as an escape hatch. 276 277 ### Everything else 278 279 The AI key and both volume keys go through the accessibility filter, which gives 280 the full vocabulary: 1–5 taps, press-and-hold, and any combination of those 281 three keys, each bindable separately. 282 283 The gesture engine (`engine/GestureEngine.kt`) is deliberately free of Android 284 types so the state machine is unit-testable on the JVM. Its one rule that 285 matters for feel: *a key with no bindings is never consumed, and a key whose 286 highest bound tap count is 1 fires on key-up without waiting out the multi-tap 287 window.* You only pay multi-tap latency on keys where you actually asked for a 288 double tap. 289 290 ## Navigation 291 292 *Navigation* on the main screen mixes three ways of getting around: the 293 three-button bar, swipe gestures, and Power key combinations (defaults: Power 294 then Volume up = Back, the AI key = Home, Volume down = Recents). Five named 295 setups, or tick any mix. 296 297 The combinations are the app's own. The bar and the gestures are the system's, 298 and Android lets no app switch them, so the screen shows the current state and 299 the commands, and `tools/nav-mode` runs them: 300 301 ```bash 302 tools/nav-mode buttons nogestures # bar only, no gestures at all 303 tools/nav-mode nobuttons gestures # gestures only 304 tools/nav-mode nobuttons nogestures # neither: Power combinations only 305 tools/nav-mode status 306 ``` 307 308 | Piece | Switch | 309 |---|---| 310 | Button bar | overlay `com.android.internal.systemui.navbar.threebutton` / `.gestural` (gestural removes the bar outright on this firmware) | 311 | Swipe up for Home (Viwoods' own, both modes) | `Settings.System disable_gesture_bottom` | 312 | Edge swipe for Back (gestural mode only) | `Settings.Secure back_gesture_inset_scale_left/right` = 0 | 313 314 Order matters: changing the overlay makes SystemUI forget 315 `disable_gesture_bottom`, so the overlay goes first. 316 317 Back, Home and Recents on a Power combination keep working when the app is 318 locked. A reader with no bar and no gestures is navigated entirely by them, and 319 an expired trial must not turn it into a brick. 320 321 ## Actions 322 323 Parity with the stock Viwoods key screen, plus everything it does not offer: 324 325 - **Viwoods AI** — AI crop, AI lookup, quick prompt, full assistant, history, 326 repository, edit screenshot, Viwoods home 327 - **Navigation** — Back, Home, Recents, notification shade, quick settings, 328 power menu, lock screen, screenshot, D-pad, play/pause 329 - **Page turning** — synthetic swipes and scrolls, for readers that only take 330 touch 331 - **Sound and media** — volume up/down/mute/panel, play-pause, next, previous 332 - **Anything else** — open an app, open a specific `package/class` (reaches 333 activities with no launcher icon), send an intent action, send a broadcast 334 - **Do nothing** — swallows the key, which is how a button gets disabled 335 336 ## Installing 337 338 From Google Play, or sideload `AssistKey-<version>.apk` from the GitHub 339 release. Both are the same build with the same signature. 340 341 On a Viwoods reader Google Play is switched off out of the box. The app works 342 without it; buying a licence does not. 343 344 ### The accessibility switch will not stay on until you do this 345 346 Android blocks **restricted settings** for anything installed outside an app 347 store, and accessibility is one of them. The symptom is silent and confusing: 348 the switch appears to turn on, then reverts a moment later with no message. 349 350 *App info → three-dot menu → Allow restricted settings*, then enable the 351 service. The app links straight to App info from the accessibility channel. 352 353 Over adb the equivalent is: 354 355 ```bash 356 adb shell appops set dev.equwal.assistkey ACCESS_RESTRICTED_SETTINGS allow 357 ``` 358 359 Note that `adb shell am force-stop` on this app disables its accessibility 360 service again, so re-enable it after any force-stop or reinstall. 361 362 ### Optional: the firmware power switches 363 364 ```bash 365 adb shell pm grant dev.equwal.assistkey android.permission.WRITE_SECURE_SETTINGS 366 ``` 367 368 Without that grant everything still works except the firmware-level power 369 switches (short press, hold duration, the double-press gesture toggles). The app 370 detects the refusal and prints the exact `adb` command instead of failing 371 silently. 372 373 Note that this permission only unlocks *writes*. Since Android 12 those keys 374 cannot be **read** by a non-system app at all — `Settings.Global.getInt` throws 375 `SecurityException` — so the app shows their current values as "unknown". That 376 is a platform restriction, not a bug. 377 378 ### Key tester 379 380 *Advanced → Key tester* lists every key event that reaches the filter. It is the 381 only reliable way to find out what a given device allows, because a key consumed 382 upstream by the window manager never reaches any app and simply never appears. 383 Note that events injected with `adb shell input keyevent` **bypass** 384 accessibility input filters entirely, so only real presses tell you anything. 385 386 ## Licensing 387 388 Four tiers, tried in order (`license/License.kt`): 389 390 | Tier | When | Effect | 391 |---|---|---| 392 | Licensed | Play reports `assistkey_pro` or `assistkey_pro_tester` owned | Everything works; cached, so it survives being offline | 393 | Beta | The beta is open | Everything works, free; the install marks itself as a tester | 394 | Trial | Beta closed, under 7 days since first launch | Everything works | 395 | Locked | Otherwise | Key filter and entry points go inert; bindings are kept | 396 397 "Is the beta open" is answered by Google Play, not by a server. A third 398 product, `assistkey_beta_open`, is never sold and exists only as a flag: 399 deactivate it in Play Console and the beta ends everywhere. Only "paid product 400 visible **and** flag missing", in one response, reads as closed — so a failed 401 or empty answer can never lock anyone out. Installs that cannot reach Play fall 402 back to `assistkey.betaExpires` in `gradle.properties`. 403 404 Testers are then offered `assistkey_pro_tester`, the same licence for less. A 405 tester on a new device types the tester code instead; only its SHA-256 ships. 406 407 The billing library's telemetry runtime (`datatransport`) is excluded from the 408 build, because it would merge `INTERNET` into the manifest. The library wraps 409 that runtime's start-up in a catch-all and logs *"Skipping logging since 410 initialization failed"*; that was confirmed in bytecode and on the device. 411 **Re-check it before bumping the billing version.** 412 413 ## Building 414 415 ```bash 416 ./gradlew assembleFullRelease assemblePlayRelease bundlePlayRelease 417 ``` 418 419 `bundlePlayRelease` makes the `.aab` for Play. `assembleFullRelease` makes the 420 APK with shell access for direct install. `assemblePlayRelease` makes the store 421 build as an APK, for testing it on a device. The version comes from `gradle.properties`; `versionCode` must rise with 422 every upload. 423 424 Release signing is read from `keystore.properties` at the repo root, which is 425 not committed: 426 427 ```properties 428 storeFile=assistkey-release.jks 429 storePassword=… 430 keyAlias=assistkey 431 keyPassword=… 432 testerCode=… 433 ``` 434 435 Without it the release build still runs and produces an unsigned APK, so a fresh 436 clone is never broken — it just cannot ship. **Back up the keystore**: losing it 437 means shipped installs can never be upgraded. 438 439 ### Windows note 440 441 If Gradle dies with `Unable to establish loopback connection`, `java.io.tmpdir` 442 is resolving through an 8.3 short path (`C:\Users\ADMINI~1\…`), which breaks the 443 JDK's AF_UNIX-backed `Selector.open()`. Point `TMP` and `TEMP` at a short path 444 such as `C:\tmp` and it goes away. 445 446 ## Uninstalling cleanly 447 448 Uninstalling restores every key to its firmware behaviour. The app writes 449 nothing that outlives it, unless you granted `WRITE_SECURE_SETTINGS` and changed 450 the firmware power switches, which are ordinary system settings. 451 452 ## Layout 453 454 ``` 455 model/ keys, triggers, action specs, the recovered Viwoods component list 456 engine/ the gesture state machine and the accessibility service 457 channel/ the key filter switch and the Power side doors (assistant, camera, wallet) 458 device/ device profiles 459 display/ the extra-dim light 460 home/ the stand-in that opens Ink Recents 461 bundle/ apps of other makers that the full build carries (per flavour) 462 menu/ the menu of actions 463 shell/ shell access through Shizuku, and the managed Power button 464 voice/ voice typing: recognizer choice, the listening screen, text insert 465 license/ licence tiers and Google Play Billing 466 native/ firmware settings: power gestures, and the read-only key hooks 467 route/ turning an action spec into behaviour 468 store/ persistence, with the key-event hot path precomputed 469 ui/ the configuration screens, built in code 470 471 tools/ adb helpers: nav-mode (bar and gestures), ai-key (the AI key hook) 472 tools/e2e/ tests that run on a device, through the kernel input nodes 473 play/ everything for the Play Console: checklist, listing, policy, graphics 474 src/debug/ a debug-only hook that renders each screen to a PNG, because the 475 e-ink panel defeats `adb shell screencap` 476 ```