Krolyxon
Commit 8ec1f82

Branches

Refs →

budctl8ec1f82

budctl-core: build for Android

The first step of the Android app (docs/android.md). Android has no
BlueZ, and apps cannot open the kernel's Bluetooth sockets, so the app
will open the link in Kotlin and hand it to the core as a Transport.

- BlueZ, the RFCOMM and L2CAP sockets, SDP, scan() and connect() are
  Linux-only (cfg(target_os = "linux")); Android's target OS is
  "android". libc and zbus become Linux-only dependencies, so the
  Android build of the core needs only serde and serde_json.
- PairedDevice moves from bluez to the crate root, since the Android
  app fills it too.
- CI runs clippy on the core for aarch64-linux-android (a check needs
  no NDK).
- docs/android.md: the plan, its steps and the phone checkpoints.
  CLAUDE.md points to it from the goals and the roadmap.
Author
krolyxon
Date
2026-11-01 22:05 +0530
Parent
828a2c7
Commit
8ec1f823e94c5bb255a28c755ac6634de32dd42c

+248 −32

12 files changed

  1. M .github/workflows/ci.yml +3 −0
  2. M CLAUDE.md +8 −7
  3. M crates/budctl-cli/src/output.rs +1 −1
  4. M crates/budctl-core/Cargo.toml +5 −1
  5. M crates/budctl-core/src/bluez.rs +1 −14
  6. M crates/budctl-core/src/bt.rs +3 −0
  7. M crates/budctl-core/src/devices/mod.rs +2 −2
  8. M crates/budctl-core/src/devices/realme_t310/mod.rs +1 −1
  9. M crates/budctl-core/src/lib.rs +24 −4
  10. M crates/budctl-core/src/reader.rs +5 −0
  11. M crates/budctl-core/src/transport/mod.rs +9 −2
  12. A docs/android.md +186 −0
M .github/workflows/ci.yml View
@@ -15,8 +15,11 @@ jobs:
1515 - uses: dtolnay/rust-toolchain@stable
1616 with:
1717 components: clippy, rustfmt
18+ targets: aarch64-linux-android
1819 - run: sudo apt-get update && sudo apt-get install -y pkg-config libwayland-dev libxkbcommon-dev
1920 - run: cargo fmt --all -- --check
2021 - run: cargo clippy --workspace --all-targets --locked -- -D warnings
22+ # The core also goes into the Android app (docs/android.md). A check needs no NDK.
23+ - run: cargo clippy -p budctl-core --target aarch64-linux-android --locked -- -D warnings
2124 - run: cargo test --workspace --locked
2225 - run: sh -n install.sh
M CLAUDE.md View
@@ -7,7 +7,7 @@ The first supported device is the **realme Buds T310**. More devices are added a
77 ## Goals
88
99 - Control earbud features from Linux: ANC / transparency / off, EQ presets, game mode, touch controls, battery readout, and anything else the buds offer.
10−- Two front ends, one core: the `budctl` CLI and the `budctl-gui` desktop app, both thin layers over the same library (`budctl-core`). Scripts and the owner's Quickshell (Aurora) can use `budctl --json`.
10+- Front ends over one core: the `budctl` CLI and the `budctl-gui` desktop app, and next an Android app (plan: `docs/android.md`), all thin layers over the same library (`budctl-core`). Scripts and the owner's Quickshell (Aurora) can use `budctl --json`.
1111 - One small, readable codebase that the owner fully understands.
1212 - Device support is pluggable. Adding a new pair of buds means adding one module under `crates/budctl-core/src/devices/` and registering it in `devices/mod.rs`, without touching transport, CLI or GUI code.
1313 - Coexist with the phone app. Settings live on the buds, so budctl never stores device state locally beyond user config (`~/.config/budctl/config.json`, `budctl_core::config`: default device and GUI preferences, created with defaults when missing; a broken file is reported and the defaults are used).
@@ -33,17 +33,17 @@ The first supported device is the **realme Buds T310**. More devices are added a
3333 ## Stack
3434
3535 - Rust (edition 2024) in a cargo workspace:
36− - `crates/budctl-core`: library with transport, BlueZ and device plugins.
36+ - `crates/budctl-core`: library with transport, BlueZ and device plugins. It builds for Android too (`aarch64-linux-android`): BlueZ, the sockets and SDP are Linux-only (`cfg(target_os = "linux")`).
3737 - `crates/budctl-cli`: package and binary `budctl`.
3838 - `crates/budctl-gui`: package and binary `budctl-gui`, built with iced 0.14 for Wayland only, with the CPU renderer (tiny-skia).
39−- Bluetooth goes through BlueZ:
39+- On Linux, Bluetooth goes through BlueZ:
4040 - Raw `AF_BLUETOOTH` sockets via `libc`: RFCOMM for the control link, L2CAP to read the buds' SDP record.
4141 - `zbus` (blocking API) only to list paired devices.
4242 - Supporting BLE GATT would need a new transport kind in core.
4343 - CLI: `clap` (derive). It is parsed with `try_parse`, so usage errors exit 1, not clap's default 2. `serde_json` provides `--json`.
44−- Tests: `cargo test --workspace`. Lints: `cargo clippy --workspace --all-targets -- -D warnings`.
44+- Tests: `cargo test --workspace`. Lints: `cargo clippy --workspace --all-targets -- -D warnings`, and for Android `cargo clippy -p budctl-core --target aarch64-linux-android -- -D warnings`.
4545 - Keep dependencies minimal. Every new dependency needs a reason in the commit message. The current direct dependencies:
46− - core: `libc`, `zbus`, `serde`, `serde_json` (config file)
46+ - core: `serde`, `serde_json` (config file); on Linux only, `libc` and `zbus`
4747 - cli: `clap`, `serde_json`
4848 - gui: `iced`, `serde`, `serde_json`; `iced_test` for tests
4949
@@ -51,7 +51,7 @@ The first supported device is the **realme Buds T310**. More devices are added a
5151
5252 Each module in `crates/budctl-core/src/devices/` defines one `Device` implementation and is listed in `devices::REGISTRY`. A device declares:
5353
54−- `name()`, plus `matches()`: how to recognize it from BlueZ data (Bluetooth name and/or service UUID).
54+- `name()`, plus `matches()`: how to recognize it from what the system reports about a paired device (Bluetooth name and/or service UUID).
5555 - `rfcomm_uuids()`: the transport. SDP turns the UUID into an RFCOMM channel.
5656 - `open()`: runs any init sequence the buds need before they accept commands, and returns a `Session`.
5757 - The features the model supports. The CLI and GUI only show features the connected device declares.
@@ -110,7 +110,8 @@ Exit codes:
110110 10. ~~fit test~~ (dropped: the T310 acks it but runs no test)
111111 11. custom EQ
112112 4. **GUI** (`budctl-gui`, see [GUI](#gui)): first the look, with demo data; then the keys; then the live connection; then the pages, one at a time.
113−5. **More devices**: as acquired.
113+5. **Android app** (`docs/android.md#steps`): a Kotlin app with budctl-core inside, to replace the other control app on the owner's phone. First the core builds for Android and the session worker moves into core; then the JNI crate, the Bluetooth link and a plain screen (phone checkpoints A1, A2); then the pages, one at a time.
114+6. **More devices**: as acquired.
114115
115116 ## GUI
116117
M crates/budctl-cli/src/output.rs View
@@ -2,7 +2,7 @@
22
33 use std::io::{self, Write};
44
5−use budctl_core::bluez::PairedDevice;
5+use budctl_core::PairedDevice;
66 use budctl_core::devices::Device;
77 use budctl_core::status::{
88 Charge, DualDevice, EqPreset, EqPresets, JSON_SCHEMA, NoiseControl, Status, Switch, TouchChange, TouchControls,
M crates/budctl-core/Cargo.toml View
@@ -8,9 +8,13 @@ license.workspace = true
88 publish.workspace = true
99
1010 [dependencies]
11−libc = "0.2.191"
1211 serde = { version = "1.0.229", features = ["derive"] }
1312 serde_json = "1.0.154"
13+
14+# BlueZ and the kernel's Bluetooth sockets. Android has neither: its app opens the link in Kotlin
15+# (docs/android.md).
16+[target.'cfg(target_os = "linux")'.dependencies]
17+libc = "0.2.191"
1418 zbus = { version = "5.19.0", default-features = false, features = ["blocking-api", "async-io"] }
1519
1620 [lints]
M crates/budctl-core/src/bluez.rs View
@@ -10,25 +10,12 @@ use std::os::unix::net::UnixStream;
1010 use zbus::blocking::connection::Builder;
1111 use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
1212
13−use crate::bt::BdAddr;
13+use crate::PairedDevice;
1414 use crate::error::{Error, Result};
1515
1616 /// The system bus. `DBUS_SYSTEM_BUS_ADDRESS` is deliberately ignored.
1717 const SYSTEM_BUS_SOCKET: &str = "/var/run/dbus/system_bus_socket";
1818
19−/// A device BlueZ has paired, as BlueZ describes it.
20−#[derive(Debug, Clone)]
21−pub struct PairedDevice {
22− pub address: BdAddr,
23− /// The name the device advertises. Device modules recognize models by this.
24− pub name: String,
25− /// The name the user gave it (BlueZ falls back to `name`). Shown to people.
26− pub alias: String,
27− pub connected: bool,
28− /// Service UUIDs BlueZ found, lowercase and hyphenated.
29− pub uuids: Vec<String>,
30−}
31−
3219 type Properties = HashMap<String, OwnedValue>;
3320 type ManagedObjects = HashMap<OwnedObjectPath, HashMap<String, Properties>>;
3421
M crates/budctl-core/src/bt.rs View
@@ -5,6 +5,7 @@ use std::str::FromStr;
55
66 use serde::{Serialize, Serializer};
77
8+#[cfg(target_os = "linux")]
89 use crate::error::{Error, Result};
910
1011 /// A Bluetooth device address, kept in the order people write it (`AA:BB:CC:DD:EE:FF`).
@@ -19,6 +20,7 @@ impl BdAddr {
1920
2021 /// The address as the kernel's `bdaddr_t` stores it: reversed.
2122 /// Spec: transport.md#sockets
23+ #[cfg(target_os = "linux")]
2224 pub(crate) fn kernel_order(self) -> [u8; 6] {
2325 let mut bytes = self.0;
2426 bytes.reverse();
@@ -99,6 +101,7 @@ impl Uuid {
99101
100102 /// A UUID as SDP encodes it: 2, 4 or 16 big-endian bytes.
101103 /// Spec: transport.md#sdp-data-elements
104+ #[cfg(target_os = "linux")]
102105 pub(crate) fn from_sdp(bytes: &[u8]) -> Result<Self> {
103106 match *bytes {
104107 [a, b] => Ok(Uuid::from_u32(u32::from(u16::from_be_bytes([a, b])))),
M crates/budctl-core/src/devices/mod.rs View
@@ -3,7 +3,7 @@
33 //! To support a new pair of buds, add one module here and list it in [`REGISTRY`]. Transport,
44 //! CLI and GUI code do not change.
55
6−use crate::bluez::PairedDevice;
6+use crate::PairedDevice;
77 use crate::bt::Uuid;
88 use crate::error::{Error, Result};
99 use crate::status::{
@@ -21,7 +21,7 @@ pub trait Device: Sync {
2121 /// Model name, e.g. `realme Buds T310`.
2222 fn name(&self) -> &'static str;
2323
24− /// Whether a paired device is this model, judged from BlueZ data alone.
24+ /// Whether a paired device is this model, judged from what the system reports about it.
2525 fn matches(&self, device: &PairedDevice) -> bool;
2626
2727 /// The control service(s), in the order to try. SDP turns each into an RFCOMM channel.
M crates/budctl-core/src/devices/realme_t310/mod.rs View
@@ -5,7 +5,7 @@
55
66 use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
77
8−use crate::bluez::PairedDevice;
8+use crate::PairedDevice;
99 use crate::bt::Uuid;
1010 use crate::devices::{Device, Session};
1111 use crate::error::{Error, Kind, Result};
M crates/budctl-core/src/lib.rs View
@@ -1,10 +1,12 @@
11 //! budctl-core: control wireless earbuds over Bluetooth, with device support as plugins.
22 //!
3−//! A front end (CLI, GUI) calls [`scan`], picks a device with [`select`], opens a session with
4−//! [`connect`] and then reads or changes settings through [`devices::Session`].
3+//! On Linux, a front end (CLI, GUI) calls [`scan`], picks a device with [`select`], opens a session
4+//! with [`connect`] and then reads or changes settings through [`devices::Session`]. On Android,
5+//! the app's Kotlin side lists the paired devices and opens the link (docs/android.md).
56 //!
67 //! No telemetry: the only sockets are Bluetooth sockets and the local system D-Bus (BlueZ).
78
9+#[cfg(target_os = "linux")]
810 pub mod bluez;
911 pub mod bt;
1012 pub mod config;
@@ -17,12 +19,28 @@ pub mod transport;
1719
1820 pub use error::{Error, Kind, Result};
1921
20−use bluez::PairedDevice;
2122 use bt::BdAddr;
22−use devices::{Device, Session};
23+use devices::Device;
24+#[cfg(target_os = "linux")]
25+use devices::Session;
26+#[cfg(target_os = "linux")]
2327 use transport::rfcomm::Rfcomm;
28+#[cfg(target_os = "linux")]
2429 use transport::{Hexdump, Transport, sdp};
2530
31+/// A paired device, as the system describes it: BlueZ on Linux, the app's Kotlin side on Android.
32+#[derive(Debug, Clone)]
33+pub struct PairedDevice {
34+ pub address: BdAddr,
35+ /// The name the device advertises. Device modules recognize models by this.
36+ pub name: String,
37+ /// The name the user gave it (the system falls back to `name`). Shown to people.
38+ pub alias: String,
39+ pub connected: bool,
40+ /// The device's service UUIDs, lowercase and hyphenated.
41+ pub uuids: Vec<String>,
42+}
43+
2644 /// A paired device and the model budctl recognized it as, if any.
2745 pub struct Found {
2846 pub device: PairedDevice,
@@ -30,6 +48,7 @@ pub struct Found {
3048 }
3149
3250 /// Every paired device BlueZ knows, each with its model when budctl supports it.
51+#[cfg(target_os = "linux")]
3352 pub fn scan() -> Result<Vec<Found>> {
3453 Ok(bluez::paired_devices()?
3554 .into_iter()
@@ -94,6 +113,7 @@ pub fn select(found: Vec<Found>, address: Option<BdAddr>) -> Result<(PairedDevic
94113
95114 /// Open a control session: find the control service with SDP, connect RFCOMM, run the model's
96115 /// init sequence.
116+#[cfg(target_os = "linux")]
97117 pub fn connect(device: &PairedDevice, model: &'static dyn Device) -> Result<Box<dyn Session>> {
98118 if !device.connected {
99119 return Err(Error::NotConnected(format!(
M crates/budctl-core/src/reader.rs View
@@ -28,10 +28,13 @@ impl<'a> Reader<'a> {
2828 Ok(u16::from_le_bytes(self.array()?))
2929 }
3030
31+ /// Only SDP, which is Linux-only, reads big-endian numbers so far.
32+ #[cfg(target_os = "linux")]
3133 pub(crate) fn u16_be(&mut self) -> Result<u16> {
3234 Ok(u16::from_be_bytes(self.array()?))
3335 }
3436
37+ #[cfg(target_os = "linux")]
3538 pub(crate) fn u32_be(&mut self) -> Result<u32> {
3639 Ok(u32::from_be_bytes(self.array()?))
3740 }
@@ -48,6 +51,8 @@ impl<'a> Reader<'a> {
4851 std::mem::take(&mut self.bytes)
4952 }
5053
54+ /// Only SDP checks that it read everything so far.
55+ #[cfg(target_os = "linux")]
5156 pub(crate) fn is_empty(&self) -> bool {
5257 self.bytes.is_empty()
5358 }
M crates/budctl-core/src/transport/mod.rs View
@@ -1,14 +1,20 @@
11 //! Byte transports. Device protocols only see the [`Transport`] trait, so they can be tested
22 //! against a scripted mock instead of real buds.
33
4+#[cfg(target_os = "linux")]
45 use std::io;
56 use std::time::Instant;
67
78 use crate::debug;
8−use crate::error::{Error, Result};
9+#[cfg(target_os = "linux")]
10+use crate::error::Error;
11+use crate::error::Result;
912
13+#[cfg(target_os = "linux")]
1014 pub mod rfcomm;
15+#[cfg(target_os = "linux")]
1116 pub mod sdp;
17+#[cfg(target_os = "linux")]
1218 mod socket;
1319
1420 #[cfg(test)]
@@ -61,6 +67,7 @@ impl Transport for Hexdump {
6167 /// What an OS error on a Bluetooth socket means for budctl.
6268 /// `may_be_busy` is set only for the RFCOMM connect, where a refusal means another client
6369 /// holds the channel. Spec: transport.md#errno
70+#[cfg(target_os = "linux")]
6471 pub(crate) fn classify(error: io::Error, context: &'static str, may_be_busy: bool) -> Error {
6572 let detail = || format!("{context}: {error}");
6673 match error.raw_os_error() {
@@ -80,7 +87,7 @@ pub(crate) fn classify(error: io::Error, context: &'static str, may_be_busy: boo
8087 }
8188 }
8289
83−#[cfg(test)]
90+#[cfg(all(test, target_os = "linux"))]
8491 mod tests {
8592 use super::*;
8693 use crate::error::Kind;
A docs/android.md View
@@ -0,0 +1,186 @@
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.