Krolyxon
Commit 832675a

Branches

Refs →

budctldocsandroid.md

211 lines · 12.4 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- **No `INTERNET` permission.** Android then enforces rule 1: the app cannot open a network
87 socket. A build check fails if a library adds the permission to the merged manifest.
88- **Paired devices:** `BluetoothAdapter.getBondedDevices()` gives the name, alias, address and
89 cached service UUIDs. Whether the buds are connected comes from the A2DP and headset profile
90 proxies.
91- **Connect:** `createRfcommSocketToServiceRecord(uuid)`, for the first of the model's
92 `rfcomm_uuids()` that the buds list (Android caches their service UUIDs; with none cached yet,
93 the first one). Android runs the SDP lookup that `sdp.rs` does on Linux. `connect()` takes no
94 timeout, so a watchdog closes the socket after 8 s, when `rfcomm.rs` gives up too.
95- **Reads with a timeout:** the socket's input stream has no read timeout. A reader thread puts
96 what it reads in a queue, `recv(timeoutMs)` waits on the queue, and `close()` ends the thread.
97 The end of the stream means the buds closed the link (case lid closed?).
98- **Errors:** Android reports a failed connect as a plain `IOException`, with no errno. When the
99 buds are not connected to the phone, it is `not-connected`. When they are and RFCOMM still
100 fails, it is `busy` ("control channel busy — is another app connected?").
101- **Letting go of the channel:** the session closes 10 s after the last command, as on the
102 desktop, and also when the app leaves the screen (`onStop`). There is no background service, so
103 the laptop is never locked out for long.
104- **Debug:** a switch in Settings turns on the hex dump. `budctl_core::debug` gets a pluggable
105 output (stderr by default). On Android it goes to logcat and to an in-app log that can be
106 copied, so a checkpoint's output can be pasted straight from the phone.
107
108<a id="steps"></a>
109## Steps
110
1110. **Toolchain:** Android Studio, which brings the SDK (`~/Android/Sdk`), the emulator and a JDK
112 for Gradle (`/opt/android-studio/jbr`). The NDK, from Studio's SDK Manager.
113 `rustup target add aarch64-linux-android x86_64-linux-android` and `cargo install cargo-ndk`.
114 *Done when* the NDK is in `~/Android/Sdk/ndk` and `adb devices` lists the emulator. The phone
115 joins before A1, over USB debugging (`android-udev` gives adb access to it without root).
1161. **The core builds for Android.** `bluez`, `transport::{rfcomm, sdp, socket}`, `scan` and
117 `connect` go behind `cfg(target_os = "linux")`, and `libc` and `zbus` become Linux-only
118 dependencies. `PairedDevice` moves to the crate root, since Android fills it too. CI checks the
119 core for `aarch64-linux-android`.
120 *Done when* `cargo clippy -p budctl-core --target aarch64-linux-android -- -D warnings` passes
121 and nothing changes on Linux.
1222. **The worker moves into core** (`budctl_core::worker`), with its tests. A `Report` trait takes
123 its events and log lines. The desktop app's `worker.rs` keeps only the iced subscription and
124 the BlueZ connect.
125 *Done when* the desktop app behaves the same and all tests pass.
1263. **`crates/budctl-android`:** the JNI exports, `Deserialize` for the commands and `Serialize`
127 for the events, the `Transport` over Kotlin's link, and the pluggable debug output. Rule 5
128 needs an exception first (see [Decisions](#decisions)).
129 *Done when* `cargo ndk -t arm64-v8a build -p budctl-android` builds and the crate's tests
130 (JSON round trips, a scripted link) pass on Linux.
1314. **The Android project** (`android/`): Gradle, with `cargo ndk` run from Gradle, `Bluetooth.kt`,
132 `Link.kt`, `Core.kt`, and a plain screen with the status as text, the noise-control choice and
133 the debug log. JVM tests cover the link's reader thread and timeouts. Build and install with
134 `cd android && ./gradlew assembleDebug` and
135 `adb install -r app/build/outputs/apk/debug/app-debug.apk`.
136 *Done when* **A1** and **A2** pass on the phone.
1375. **The pages**, built the way the desktop app was: first the look with demo buds, then live
138 buds, then one page at a time (Overview, Equalizer, Gestures, Earbuds, Settings).
139 - The look is the desktop app's: Nothing OS shapes and type in Material You colors. The colors
140 come from Android's dynamic color (the phone's wallpaper palette), mapped to the same roles;
141 a switch on the APP page puts the built-in near-black palette back.
142 - The same fonts, from `crates/budctl-gui/fonts` (Gradle adds that folder to the assets). Icons,
143 battery rings, dotted sliders and pill switches are drawn on Compose canvases, with
144 `motion.rs`'s timings.
145 - The desktop's sidebar becomes a row of dot-matrix page words above the page. A swipe changes
146 the page, and the pages fade into each other rather than slide.
147 - Touch only: no vim keys. The APP page keeps the debug switch and the log, with a copy
148 button.
149 - Claude checks the look on the emulator with the demo buds, from adb screenshots: launch with
150 `--ez demo true`.
151 - Built: the look and every page of the desktop app (Overview, Equalizer, Gestures, Earbud
152 settings), plus the APP page.
153 *Done when* **A3** passes for every page.
1546. **Release.** One version for everything: Gradle reads it from `Cargo.toml` (versionCode
155 `major * 10000 + minor * 100 + patch`). CI builds and tests the app on every push, and a tag adds
156 `budctl.apk` and its `.sha256` to the GitHub release. No Play Store. New versions come from the
157 GitHub releases (an app such as Obtainium can watch them); budctl itself never checks.
158 - The signing key stays with the owner: `~/.local/share/budctl-release/` on the owner's machine
159 (the keystore, alias `budctl`, and its password), and the repository's secrets
160 `BUDCTL_KEYSTORE_BASE64` and `BUDCTL_KEYSTORE_PASSWORD`. Gradle signs release builds when
161 `BUDCTL_KEYSTORE` and `BUDCTL_KEYSTORE_PASSWORD` are set, and leaves them unsigned otherwise.
162 Losing the key means a new one, and every phone then has to uninstall before it can update.
163 - Certificate SHA-256:
164 `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`.
165 *Done when* a tagged release carries the APK and the phone runs it.
1667. **Retire the other control app on the phone** (the owner), once **A4** passes.
167
168<a id="emulator"></a>
169## Emulator and phone
170
171- The emulator (x86_64, from Android Studio) runs the app for everything but the buds: the build,
172 the JNI library, the permission prompt, leaving and coming back, and the pages with the demo
173 buds. Claude drives it over adb and takes the screenshots, so none of that waits for the owner.
174- Its Bluetooth is simulated: a virtual adapter that only sees other emulated devices, never the
175 buds. Everything that talks to the buds (A1–A4) runs on the phone.
176- So debug builds carry x86_64 for the emulator and arm64-v8a for the phone. The release APK
177 carries arm64-v8a only.
178
179<a id="checkpoints"></a>
180## Phone checkpoints
181
182Run on the phone with debug on, and paste the in-app log.
183
184- **A1, read-only:** the app finds the buds and shows their status. Then the busy path (another
185 control app holding the channel) and the not-connected path (case lid closed).
186- **A2:** noise control off → transparency → on, read back each time. The session closes 10 s
187 after the last command, and when the app leaves the screen.
188- **A3:** each page's writes, as G3–G8 were for the desktop app.
189- **A4:** take turns between the phone app and `budctl status` on the laptop. Each gets the
190 channel once the other has let go. (On the T310 with dual connection on, both work at once:
191 each host has a control link of its own.)
192
193<a id="decisions"></a>
194## Decisions
195
196- **Taken (2026-11-01):**
197 - The app lives in this repo, in `android/`.
198 - Kotlin and Compose for the app, Rust for everything that knows the buds.
199 - `jni` and JSON, not UniFFI.
200 - arm64 for the phone and the release APK; debug builds add x86_64 for the emulator.
201 - The application id is `io.github.krolyxon.budctl`.
202 - Released as an APK on GitHub, not on the Play Store.
203 - Rule 5's second exception: the JNI exports' `#[unsafe(no_mangle)]`, in
204 `crates/budctl-android/src/lib.rs`, with nothing else unsafe in that crate.
205
206## Not planned
207
208- A background service, a persistent notification or a home-screen widget. The app talks to the
209 buds only while it is on screen.
210- Other phone architectures, Android before 12, and the Play Store.
211- Later, only if wanted: a Quick Settings tile for noise control.