Krolyxon
Commit feb7482

Branches

Refs →

budctldocsandroid.md

192 lines · 11.0 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## Why Kotlin as well as Rust
11
12- Android has no BlueZ, and apps cannot open the kernel Bluetooth sockets that
13 `transport/socket.rs` uses. An app reaches RFCOMM only through Android's `BluetoothSocket`, a
14 Java API.
15- iced does not run on Android.
16- So Kotlin does what only Android can do, Bluetooth and the screen. Rust does everything that
17 knows the buds.
18
19## What runs where
20
21```
22android/ Kotlin, built with Gradle
23 ui/ Compose: the desktop app's pages, in a phone layout
24 Bluetooth.kt paired devices; a BluetoothSocket as send / recv(timeout)
25 Core.kt the Rust library: commands in, events out, as JSON
26 │ JNI
27crates/budctl-android cdylib: the JNI exports, a Transport over Bluetooth.kt
28 │
29crates/budctl-core the protocol, unchanged: frame, link, codec, devices, worker
30```
31
32- **budctl-core** builds for Android as it is. BlueZ, the sockets and SDP are Linux-only
33 (`cfg(target_os = "linux")`). Android's target OS is `android`, not `linux`.
34- **The worker** (`budctl_core::worker`) is the thread that owns the session. It answers each
35 command with one event, answers the buds' own requests while it waits, and closes the session
36 10 s after the last command. The desktop app runs it as an iced subscription, and the Android
37 app runs the same one.
38- **budctl-android** turns JSON into worker commands and worker events into JSON, and wraps
39 Kotlin's Bluetooth link as a `Transport`. It holds no protocol logic.
40- **Kotlin** lists the paired devices, opens the RFCOMM socket and draws the pages. It holds no
41 protocol logic either: no command bytes and no tables.
42
43<a id="boundary"></a>
44## The boundary
45
46Rust exports, called from Kotlin (`Core.kt`):
47
48| Function | Does |
49|---|---|
50| `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 |
51| `command(json): String?` | queues one command; its event comes back through `platform.event`. Null, or why it was not queued |
52| `stop()` | ends the worker, which closes any session |
53
54Kotlin's side (`Platform`), called on the worker's thread. It catches its own exceptions and
55answers with values:
56
57| Method | Does |
58|---|---|
59| `pairedDevices(): String` | the paired devices as JSON: address, name, alias, connected, service UUIDs |
60| `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` |
61| `send(bytes): String?` | null, or why the link failed |
62| `recv(timeoutMs): ByteArray?` | the next bytes; null once the time runs out; an empty array once the link is closed |
63| `close()` | closes the link |
64| `event(json)` | one worker event, for the UI |
65| `log(line)` | a session or debug line, for logcat and the in-app log |
66
67Commands look like `{"command": "noise", "mode": "anc", "level": "deep"}` and events like
68`{"event": "noise", "mode": "anc", "level": "deep"}`. `crates/budctl-android/src/json.rs` lists
69them all.
70
71- JSON keeps the boundary small, and it carries the same serde types the CLI's `--json` writes.
72- Rust picks the device with `budctl_core::select`, as the CLI does, so the messages ("not
73 connected", "pick one") are the same.
74- The `jni` crate, not UniFFI: one dependency and no code generator.
75
76<a id="bluetooth"></a>
77## Bluetooth on Android
78
79Written from Android's API reference. No other app's code is read for it (rule 2).
80
81- **Permission:** `BLUETOOTH_CONNECT`, asked for on first start. minSdk is 31 (Android 12), where
82 that permission begins.
83- **No `INTERNET` permission.** Android then enforces rule 1: the app cannot open a network
84 socket. A build check fails if a library adds the permission to the merged manifest.
85- **Paired devices:** `BluetoothAdapter.getBondedDevices()` gives the name, alias, address and
86 cached service UUIDs. Whether the buds are connected comes from the A2DP and headset profile
87 proxies.
88- **Connect:** `createRfcommSocketToServiceRecord(uuid)`, for the first of the model's
89 `rfcomm_uuids()` that the buds list (Android caches their service UUIDs; with none cached yet,
90 the first one). Android runs the SDP lookup that `sdp.rs` does on Linux. `connect()` takes no
91 timeout, so a watchdog closes the socket after 8 s, when `rfcomm.rs` gives up too.
92- **Reads with a timeout:** the socket's input stream has no read timeout. A reader thread puts
93 what it reads in a queue, `recv(timeoutMs)` waits on the queue, and `close()` ends the thread.
94 The end of the stream means the buds closed the link (case lid closed?).
95- **Errors:** Android reports a failed connect as a plain `IOException`, with no errno. When the
96 buds are not connected to the phone, it is `not-connected`. When they are and RFCOMM still
97 fails, it is `busy` ("control channel busy — is another app connected?").
98- **Letting go of the channel:** the session closes 10 s after the last command, as on the
99 desktop, and also when the app leaves the screen (`onStop`). There is no background service, so
100 the laptop is never locked out for long.
101- **Debug:** a switch in Settings turns on the hex dump. `budctl_core::debug` gets a pluggable
102 output (stderr by default). On Android it goes to logcat and to an in-app log that can be
103 copied, so a checkpoint's output can be pasted straight from the phone.
104
105<a id="steps"></a>
106## Steps
107
1080. **Toolchain:** Android Studio, which brings the SDK (`~/Android/Sdk`), the emulator and a JDK
109 for Gradle (`/opt/android-studio/jbr`). The NDK, from Studio's SDK Manager.
110 `rustup target add aarch64-linux-android x86_64-linux-android` and `cargo install cargo-ndk`.
111 *Done when* the NDK is in `~/Android/Sdk/ndk` and `adb devices` lists the emulator. The phone
112 joins before A1: wireless debugging, paired with `adb pair`.
1131. **The core builds for Android.** `bluez`, `transport::{rfcomm, sdp, socket}`, `scan` and
114 `connect` go behind `cfg(target_os = "linux")`, and `libc` and `zbus` become Linux-only
115 dependencies. `PairedDevice` moves to the crate root, since Android fills it too. CI checks the
116 core for `aarch64-linux-android`.
117 *Done when* `cargo clippy -p budctl-core --target aarch64-linux-android -- -D warnings` passes
118 and nothing changes on Linux.
1192. **The worker moves into core** (`budctl_core::worker`), with its tests. A `Report` trait takes
120 its events and log lines. The desktop app's `worker.rs` keeps only the iced subscription and
121 the BlueZ connect.
122 *Done when* the desktop app behaves the same and all tests pass.
1233. **`crates/budctl-android`:** the JNI exports, `Deserialize` for the commands and `Serialize`
124 for the events, the `Transport` over Kotlin's link, and the pluggable debug output. Rule 5
125 needs an exception first (see [Decisions](#decisions)).
126 *Done when* `cargo ndk -t arm64-v8a build -p budctl-android` builds and the crate's tests
127 (JSON round trips, a scripted link) pass on Linux.
1284. **The Android project** (`android/`): Gradle, with `cargo ndk` run from Gradle, `Bluetooth.kt`,
129 `Link.kt`, `Core.kt`, and a plain screen with the status as text, the noise-control choice and
130 the debug log. JVM tests cover the link's reader thread and timeouts. Build and install with
131 `cd android && ./gradlew assembleDebug` and
132 `adb install -r app/build/outputs/apk/debug/app-debug.apk`.
133 *Done when* **A1** and **A2** pass on the phone.
1345. **The pages**, built the way the desktop app was: first the look with demo buds, then live
135 buds, then one page at a time (Overview, Equalizer, Gestures, Earbuds, Settings).
136 - The look is the desktop app's: Nothing OS shapes and type in Material You colors. The colors
137 come from Android's dynamic color (the phone's wallpaper palette), mapped to the same roles.
138 - The same fonts, from `crates/budctl-gui/fonts`. Battery rings, dotted sliders and pill
139 switches are drawn on Compose canvases, with `motion.rs`'s timings.
140 - Touch only: no vim keys.
141 - Claude checks the look on the emulator, with the demo buds, from adb screenshots.
142 *Done when* **A3** passes for every page.
1436. **Release.** One version for everything: Gradle reads it from `Cargo.toml`. CI builds and
144 tests the app on every push, and a tag adds a signed APK to the GitHub release. The signing key
145 stays with the owner, as a CI secret. No Play Store. New versions come from the GitHub releases
146 (an app such as Obtainium can watch them); budctl itself never checks.
147 *Done when* a tagged release carries the APK and the phone runs it.
1487. **Retire the other control app on the phone** (the owner), once **A4** passes.
149
150<a id="emulator"></a>
151## Emulator and phone
152
153- The emulator (x86_64, from Android Studio) runs the app for everything but the buds: the build,
154 the JNI library, the permission prompt, leaving and coming back, and the pages with the demo
155 buds. Claude drives it over adb and takes the screenshots, so none of that waits for the owner.
156- Its Bluetooth is simulated: a virtual adapter that only sees other emulated devices, never the
157 buds. Everything that talks to the buds (A1–A4) runs on the phone.
158- So debug builds carry x86_64 for the emulator and arm64-v8a for the phone. The release APK
159 carries arm64-v8a only.
160
161<a id="checkpoints"></a>
162## Phone checkpoints
163
164Run on the phone with debug on, and paste the in-app log.
165
166- **A1, read-only:** the app finds the buds and shows their status. Then the busy path (another
167 control app holding the channel) and the not-connected path (case lid closed).
168- **A2:** noise control off → transparency → on, read back each time. The session closes 10 s
169 after the last command, and when the app leaves the screen.
170- **A3:** each page's writes, as G3–G8 were for the desktop app.
171- **A4:** take turns between the phone app and `budctl status` on the laptop. Each gets the
172 channel once the other has let go.
173
174<a id="decisions"></a>
175## Decisions
176
177- **Taken (2026-11-01):**
178 - The app lives in this repo, in `android/`.
179 - Kotlin and Compose for the app, Rust for everything that knows the buds.
180 - `jni` and JSON, not UniFFI.
181 - arm64 for the phone and the release APK; debug builds add x86_64 for the emulator.
182 - The application id is `io.github.krolyxon.budctl`.
183 - Released as an APK on GitHub, not on the Play Store.
184 - Rule 5's second exception: the JNI exports' `#[unsafe(no_mangle)]`, in
185 `crates/budctl-android/src/lib.rs`, with nothing else unsafe in that crate.
186
187## Not planned
188
189- A background service, a persistent notification or a home-screen widget. The app talks to the
190 buds only while it is on screen.
191- Other phone architectures, Android before 12, and the Play Store.
192- Later, only if wanted: a Quick Settings tile for noise control.