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