| 1 | # Android app |
| 2 | |
| 3 | budctl on the owner's phone, so the phone needs no other app to control the buds. It is a Kotlin |
| 4 | app with budctl-core inside it: the same protocol code, tests and spec as the CLI and the desktop |
| 5 | app. |
| 6 | |
| 7 | This 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 | ``` |
| 22 | android/ 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 |
| 27 | crates/budctl-android cdylib: the JNI exports, a Transport over Bluetooth.kt |
| 28 | │ |
| 29 | crates/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 | |
| 46 | Rust 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 | |
| 54 | Kotlin's side (`Platform`), called on the worker's thread. It catches its own exceptions and |
| 55 | answers 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 | |
| 67 | Commands 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 |
| 69 | them 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 | |
| 79 | Written 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 | |
| 108 | 0. **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, over USB debugging (`android-udev` gives adb access to it without root). |
| 113 | 1. **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. |
| 119 | 2. **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. |
| 123 | 3. **`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. |
| 128 | 4. **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. |
| 134 | 5. **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 | a switch on the APP page puts the built-in near-black palette back. |
| 139 | - The same fonts, from `crates/budctl-gui/fonts` (Gradle adds that folder to the assets). Icons, |
| 140 | battery rings, dotted sliders and pill switches are drawn on Compose canvases, with |
| 141 | `motion.rs`'s timings. |
| 142 | - The desktop's sidebar becomes a row of dot-matrix page words above the page. A swipe changes |
| 143 | the page, and the pages fade into each other rather than slide. |
| 144 | - Touch only: no vim keys. The APP page keeps the debug switch and the log, with a copy |
| 145 | button. |
| 146 | - Claude checks the look on the emulator with the demo buds, from adb screenshots: launch with |
| 147 | `--ez demo true`. |
| 148 | - Built so far: the look, the Overview and the APP page. Next: Equalizer, Gestures, Earbuds. |
| 149 | *Done when* **A3** passes for every page. |
| 150 | 6. **Release.** One version for everything: Gradle reads it from `Cargo.toml`. CI builds and |
| 151 | tests the app on every push, and a tag adds a signed APK to the GitHub release. The signing key |
| 152 | stays with the owner, as a CI secret. No Play Store. New versions come from the GitHub releases |
| 153 | (an app such as Obtainium can watch them); budctl itself never checks. |
| 154 | *Done when* a tagged release carries the APK and the phone runs it. |
| 155 | 7. **Retire the other control app on the phone** (the owner), once **A4** passes. |
| 156 | |
| 157 | <a id="emulator"></a> |
| 158 | ## Emulator and phone |
| 159 | |
| 160 | - The emulator (x86_64, from Android Studio) runs the app for everything but the buds: the build, |
| 161 | the JNI library, the permission prompt, leaving and coming back, and the pages with the demo |
| 162 | buds. Claude drives it over adb and takes the screenshots, so none of that waits for the owner. |
| 163 | - Its Bluetooth is simulated: a virtual adapter that only sees other emulated devices, never the |
| 164 | buds. Everything that talks to the buds (A1–A4) runs on the phone. |
| 165 | - So debug builds carry x86_64 for the emulator and arm64-v8a for the phone. The release APK |
| 166 | carries arm64-v8a only. |
| 167 | |
| 168 | <a id="checkpoints"></a> |
| 169 | ## Phone checkpoints |
| 170 | |
| 171 | Run on the phone with debug on, and paste the in-app log. |
| 172 | |
| 173 | - **A1, read-only:** the app finds the buds and shows their status. Then the busy path (another |
| 174 | control app holding the channel) and the not-connected path (case lid closed). |
| 175 | - **A2:** noise control off → transparency → on, read back each time. The session closes 10 s |
| 176 | after the last command, and when the app leaves the screen. |
| 177 | - **A3:** each page's writes, as G3–G8 were for the desktop app. |
| 178 | - **A4:** take turns between the phone app and `budctl status` on the laptop. Each gets the |
| 179 | channel once the other has let go. |
| 180 | |
| 181 | <a id="decisions"></a> |
| 182 | ## Decisions |
| 183 | |
| 184 | - **Taken (2026-11-01):** |
| 185 | - The app lives in this repo, in `android/`. |
| 186 | - Kotlin and Compose for the app, Rust for everything that knows the buds. |
| 187 | - `jni` and JSON, not UniFFI. |
| 188 | - arm64 for the phone and the release APK; debug builds add x86_64 for the emulator. |
| 189 | - The application id is `io.github.krolyxon.budctl`. |
| 190 | - Released as an APK on GitHub, not on the Play Store. |
| 191 | - Rule 5's second exception: the JNI exports' `#[unsafe(no_mangle)]`, in |
| 192 | `crates/budctl-android/src/lib.rs`, with nothing else unsafe in that crate. |
| 193 | |
| 194 | ## Not planned |
| 195 | |
| 196 | - A background service, a persistent notification or a home-screen widget. The app talks to the |
| 197 | buds only while it is on screen. |
| 198 | - Other phone architectures, Android before 12, and the Play Store. |
| 199 | - Later, only if wanted: a Quick Settings tile for noise control. |