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


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 ```