Krolyxon
Commit d55b50b

Branches

Refs →

budctldocsandroid.md

238 lines · 14.1 KB · Markdown

1# Android app
2
3budctl on the owner's phone, so the phone needs no other app to control the buds. It is a Kotlin
4app with budctl-core inside it: the same protocol code, tests and spec as the CLI and the desktop
5app.
6
7This file is the plan. Each step says when it is done. The phone checkpoints go into the
8[verification log](protocol/realme_t310.md#verification-log), like the desktop app's.
9
10**Done** (2026-11-02): every step, and every checkpoint passed. Release 0.1.4 carries the app as
11`budctl.apk`, and the owner has removed the other control app from the phone.
12
13## Why Kotlin as well as Rust
14
15- Android has no BlueZ, and apps cannot open the kernel Bluetooth sockets that
16 `transport/socket.rs` uses. An app reaches RFCOMM only through Android's `BluetoothSocket`, a
17 Java API.
18- iced does not run on Android.
19- So Kotlin does what only Android can do, Bluetooth and the screen. Rust does everything that
20 knows the buds.
21
22## What runs where
23
24```
25android/ Kotlin, built with Gradle
26 ui/ Compose: the desktop app's pages, in a phone layout
27 Bluetooth.kt paired devices; a BluetoothSocket as send / recv(timeout)
28 Core.kt the Rust library: commands in, events out, as JSON
29 │ JNI
30crates/budctl-android cdylib: the JNI exports, a Transport over Bluetooth.kt
31 │
32crates/budctl-core the protocol, unchanged: frame, link, codec, devices, worker
33```
34
35- **budctl-core** builds for Android as it is. BlueZ, the sockets and SDP are Linux-only
36 (`cfg(target_os = "linux")`). Android's target OS is `android`, not `linux`.
37- **The worker** (`budctl_core::worker`) is the thread that owns the session. It answers each
38 command with one event, answers the buds' own requests while it waits, and closes the session
39 10 s after the last command. The desktop app runs it as an iced subscription, and the Android
40 app runs the same one.
41- **budctl-android** turns JSON into worker commands and worker events into JSON, and wraps
42 Kotlin's Bluetooth link as a `Transport`. It holds no protocol logic.
43- **Kotlin** lists the paired devices, opens the RFCOMM socket and draws the pages. It holds no
44 protocol logic either: no command bytes and no tables.
45
46<a id="boundary"></a>
47## The boundary
48
49Rust exports, called from Kotlin (`Core.kt`):
50
51| Function | Does |
52|---|---|
53| `start(platform, idleMs, debug): String?` | starts the worker, in place of any running one; `platform` is Kotlin's side, below. Null, or why it could not start |
54| `command(json): String?` | queues one command; its event comes back through `platform.event`. Null, or why it was not queued |
55| `stop()` | ends the worker, which closes any session |
56
57Kotlin's side (`Platform`), called on the worker's thread. It catches its own exceptions and
58answers with values:
59
60| Method | Does |
61|---|---|
62| `pairedDevices(): String` | the paired devices as JSON: address, name, alias, connected, service UUIDs |
63| `connect(address, service): String?` | opens the RFCOMM link to that service, in place of any open one. Null, or `{"kind", "message"}` with the kind `busy`, `not-connected`, `timeout` or `failed` |
64| `send(bytes): String?` | null, or why the link failed |
65| `recv(timeoutMs): ByteArray?` | the next bytes; null once the time runs out; an empty array once the link is closed |
66| `close()` | closes the link |
67| `event(json)` | one worker event, for the UI |
68| `log(line)` | a session or debug line, for logcat and the in-app log |
69
70Commands look like `{"command": "noise", "mode": "anc", "level": "deep"}` and events like
71`{"event": "noise", "mode": "anc", "level": "deep"}`. `crates/budctl-android/src/json.rs` lists
72them all.
73
74- JSON keeps the boundary small, and it carries the same serde types the CLI's `--json` writes.
75- Rust picks the device with `budctl_core::select`, as the CLI does, so the messages ("not
76 connected", "pick one") are the same.
77- The `jni` crate, not UniFFI: one dependency and no code generator.
78
79<a id="bluetooth"></a>
80## Bluetooth on Android
81
82Written from Android's API reference. No other app's code is read for it (rule 2).
83
84- **Permission:** `BLUETOOTH_CONNECT`, asked for on first start. minSdk is 31 (Android 12), where
85 that permission begins.
86- **Other permissions:** only `INTERNET` and `REQUEST_INSTALL_PACKAGES`, for the
87 [update check](#updates). A build check fails if the merged manifest asks for anything else,
88 from a library too.
89- **Paired devices:** `BluetoothAdapter.getBondedDevices()` gives the name, alias, address and
90 cached service UUIDs. Whether the buds are connected comes from the A2DP and headset profile
91 proxies.
92- **Connect:** `createRfcommSocketToServiceRecord(uuid)`, for the first of the model's
93 `rfcomm_uuids()` that the buds list (Android caches their service UUIDs; with none cached yet,
94 the first one). Android runs the SDP lookup that `sdp.rs` does on Linux. `connect()` takes no
95 timeout, so a watchdog closes the socket after 8 s, when `rfcomm.rs` gives up too.
96- **Reads with a timeout:** the socket's input stream has no read timeout. A reader thread puts
97 what it reads in a queue, `recv(timeoutMs)` waits on the queue, and `close()` ends the thread.
98 The end of the stream means the buds closed the link (case lid closed?).
99- **Errors:** Android reports a failed connect as a plain `IOException`, with no errno. When the
100 buds are not connected to the phone, it is `not-connected`. When they are and RFCOMM still
101 fails, it is `busy` ("control channel busy — is another app connected?").
102- **Letting go of the channel:** the session closes 10 s after the last command, as on the
103 desktop, and also when the app leaves the screen (`onStop`). There is no background service, so
104 the laptop is never locked out for long.
105- **Debug:** a switch in Settings turns on the hex dump. `budctl_core::debug` gets a pluggable
106 output (stderr by default). On Android it goes to logcat and to an in-app log that can be
107 copied, so a checkpoint's output can be pasted straight from the phone.
108
109<a id="steps"></a>
110## Steps
111
1120. **Toolchain:** Android Studio, which brings the SDK (`~/Android/Sdk`), the emulator and a JDK
113 for Gradle (`/opt/android-studio/jbr`). The NDK, from Studio's SDK Manager.
114 `rustup target add aarch64-linux-android x86_64-linux-android` and `cargo install cargo-ndk`.
115 *Done when* the NDK is in `~/Android/Sdk/ndk` and `adb devices` lists the emulator. The phone
116 joins before A1, over USB debugging (`android-udev` gives adb access to it without root).
1171. **The core builds for Android.** `bluez`, `transport::{rfcomm, sdp, socket}`, `scan` and
118 `connect` go behind `cfg(target_os = "linux")`, and `libc` and `zbus` become Linux-only
119 dependencies. `PairedDevice` moves to the crate root, since Android fills it too. CI checks the
120 core for `aarch64-linux-android`.
121 *Done when* `cargo clippy -p budctl-core --target aarch64-linux-android -- -D warnings` passes
122 and nothing changes on Linux.
1232. **The worker moves into core** (`budctl_core::worker`), with its tests. A `Report` trait takes
124 its events and log lines. The desktop app's `worker.rs` keeps only the iced subscription and
125 the BlueZ connect.
126 *Done when* the desktop app behaves the same and all tests pass.
1273. **`crates/budctl-android`:** the JNI exports, `Deserialize` for the commands and `Serialize`
128 for the events, the `Transport` over Kotlin's link, and the pluggable debug output. Rule 5
129 needs an exception first (see [Decisions](#decisions)).
130 *Done when* `cargo ndk -t arm64-v8a build -p budctl-android` builds and the crate's tests
131 (JSON round trips, a scripted link) pass on Linux.
1324. **The Android project** (`android/`): Gradle, with `cargo ndk` run from Gradle, `Bluetooth.kt`,
133 `Link.kt`, `Core.kt`, and a plain screen with the status as text, the noise-control choice and
134 the debug log. JVM tests cover the link's reader thread and timeouts. Build and install with
135 `cd android && ./gradlew assembleDebug` and
136 `adb install -r app/build/outputs/apk/debug/app-debug.apk`.
137 *Done when* **A1** and **A2** pass on the phone.
1385. **The pages**, built the way the desktop app was: first the look with demo buds, then live
139 buds, then one page at a time (Overview, Equalizer, Gestures, Earbuds, Settings).
140 - The look is the desktop app's: Nothing OS shapes and type in Material You colors. The colors
141 come from Android's dynamic color (the phone's wallpaper palette), mapped to the same roles;
142 a switch on the APP page puts the built-in near-black palette back.
143 - The same fonts, from `crates/budctl-gui/fonts` (Gradle adds that folder to the assets). Icons,
144 battery rings, dotted sliders and pill switches are drawn on Compose canvases, with
145 `motion.rs`'s timings.
146 - The desktop's sidebar becomes a row of dot-matrix page words above the page. A swipe changes
147 the page, and the pages fade into each other rather than slide.
148 - Touch only: no vim keys. The APP page keeps the debug switch and the log, with a copy
149 button.
150 - Claude checks the look on the emulator with the demo buds, from adb screenshots: launch with
151 `--ez demo true`.
152 - Built: the look and every page of the desktop app (Overview, Equalizer, Gestures, Earbud
153 settings), plus the APP page.
154 *Done when* **A3** passes for every page.
1556. **Release.** One version for everything: Gradle reads it from `Cargo.toml` (versionCode
156 `major * 10000 + minor * 100 + patch`). CI builds and tests the app on every push, and a tag adds
157 `budctl.apk` and its `.sha256` to the GitHub release. No Play Store. New versions come from the
158 GitHub releases (an app such as Obtainium can watch them); budctl itself never checks.
159 - The signing key stays with the owner: `~/.local/share/budctl-release/` on the owner's machine
160 (the keystore, alias `budctl`, and its password), and the repository's secrets
161 `BUDCTL_KEYSTORE_BASE64` and `BUDCTL_KEYSTORE_PASSWORD`. Gradle signs release builds when
162 `BUDCTL_KEYSTORE` and `BUDCTL_KEYSTORE_PASSWORD` are set, and leaves them unsigned otherwise.
163 Losing the key means a new one, and every phone then has to uninstall before it can update.
164 - Certificate SHA-256:
165 `45:6F:9B:93:AA:40:1D:57:AA:6C:75:0D:FF:13:FA:82:5A:7B:28:B6:3D:D0:40:99:80:6F:A9:7C:14:75:23:12`.
166 *Done when* a tagged release carries the APK and the phone runs it.
1677. **Retire the other control app on the phone** (the owner), once **A4** passes.
168
169<a id="emulator"></a>
170## Emulator and phone
171
172- The emulator (x86_64, from Android Studio) runs the app for everything but the buds: the build,
173 the JNI library, the permission prompt, leaving and coming back, and the pages with the demo
174 buds. Claude drives it over adb and takes the screenshots, so none of that waits for the owner.
175- Its Bluetooth is simulated: a virtual adapter that only sees other emulated devices, never the
176 buds. Everything that talks to the buds (A1–A4) runs on the phone.
177- So debug builds carry x86_64 for the emulator and arm64-v8a for the phone. The release APK
178 carries arm64-v8a only.
179
180<a id="checkpoints"></a>
181## Phone checkpoints
182
183Run on the phone with debug on, and paste the in-app log.
184
185- **A1, read-only:** the app finds the buds and shows their status. Then the busy path (another
186 control app holding the channel) and the not-connected path (case lid closed).
187- **A2:** noise control off → transparency → on, read back each time. The session closes 10 s
188 after the last command, and when the app leaves the screen.
189- **A3:** each page's writes, as G3–G8 were for the desktop app.
190- **A4:** take turns between the phone app and `budctl status` on the laptop. Each gets the
191 channel once the other has let go. (On the T310 with dual connection on, both work at once:
192 each host has a control link of its own.)
193
194<a id="updates"></a>
195## Updates
196
197The owner installs the APK from GitHub. So the app can fetch new releases itself: rule 1's one
198exception, the owner's decision of 2026-11-02 (`Updates.kt`).
199
200- **Off by default.** A switch on the APP page turns on a check once a day, when the app opens.
201 **Check now** checks once, with the switch on or off. Nothing else goes online.
202- **What it asks:** `GET https://api.github.com/repos/krolyxon/budctl/releases/latest`, with
203 `User-Agent: budctl` and nothing about the owner or the buds. That skips drafts and
204 pre-releases. A release is newer when its tag's version code (as `app/build.gradle.kts` makes
205 it) is above the installed one.
206- **The popup:** when the daily check finds a newer release, a dialog shows its notes (the
207 CHANGELOG.md section, reflowed for a phone) with Later and Update. Later hides that version
208 until the next one; the APP page still offers it.
209- **Update:** downloads the release's `budctl.apk` into the app's cache and checks it against
210 `budctl.apk.sha256` from the same release, deleting it on a mismatch. Android's installer
211 (`PackageInstaller`) then asks before it installs, and refuses an APK not signed with budctl's
212 key. The first time, Android asks the owner to let budctl install apps.
213- **Play Protect** may offer to send the new APK to Google for a scan. *Install without scanning*
214 under More details skips it.
215- **Failures** say why, with Try again when there is a release to retry. A failed daily check
216 stays quiet and tries again the next time the app opens.
217
218<a id="decisions"></a>
219## Decisions
220
221- **Taken (2026-11-01):**
222 - The app lives in this repo, in `android/`.
223 - Kotlin and Compose for the app, Rust for everything that knows the buds.
224 - `jni` and JSON, not UniFFI.
225 - arm64 for the phone and the release APK; debug builds add x86_64 for the emulator.
226 - The application id is `io.github.krolyxon.budctl`.
227 - Released as an APK on GitHub, not on the Play Store.
228 - Rule 5's second exception: the JNI exports' `#[unsafe(no_mangle)]`, in
229 `crates/budctl-android/src/lib.rs`, with nothing else unsafe in that crate.
230- **Taken (2026-11-02):** the opt-in [update check](#updates), rule 1's one exception, with the
231 `INTERNET` and `REQUEST_INSTALL_PACKAGES` permissions it needs.
232
233## Not planned
234
235- A background service, a persistent notification or a home-screen widget. The app talks to the
236 buds only while it is on screen.
237- Other phone architectures, Android before 12, and the Play Store.
238- Later, only if wanted: a Quick Settings tile for noise control.