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.
+248 −32
12 files changed
- M .github/workflows/ci.yml +3 −0
- M CLAUDE.md +8 −7
- M crates/budctl-cli/src/output.rs +1 −1
- M crates/budctl-core/Cargo.toml +5 −1
- M crates/budctl-core/src/bluez.rs +1 −14
- M crates/budctl-core/src/bt.rs +3 −0
- M crates/budctl-core/src/devices/mod.rs +2 −2
- M crates/budctl-core/src/devices/realme_t310/mod.rs +1 −1
- M crates/budctl-core/src/lib.rs +24 −4
- M crates/budctl-core/src/reader.rs +5 −0
- M crates/budctl-core/src/transport/mod.rs +9 −2
- A docs/android.md +186 −0
| @@ -15,8 +15,11 @@ jobs: | |||
| 15 | 15 | - uses: dtolnay/rust-toolchain@stable | |
| 16 | 16 | with: | |
| 17 | 17 | components: clippy, rustfmt | |
| 18 | + | targets: aarch64-linux-android | |
| 18 | 19 | - run: sudo apt-get update && sudo apt-get install -y pkg-config libwayland-dev libxkbcommon-dev | |
| 19 | 20 | - run: cargo fmt --all -- --check | |
| 20 | 21 | - 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 | |
| 21 | 24 | - run: cargo test --workspace --locked | |
| 22 | 25 | - run: sh -n install.sh | |
| @@ -7,7 +7,7 @@ The first supported device is the **realme Buds T310**. More devices are added a | |||
| 7 | 7 | ## Goals | |
| 8 | 8 | ||
| 9 | 9 | - 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`. | |
| 11 | 11 | - One small, readable codebase that the owner fully understands. | |
| 12 | 12 | - 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. | |
| 13 | 13 | - 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 | |||
| 33 | 33 | ## Stack | |
| 34 | 34 | ||
| 35 | 35 | - 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")`). | |
| 37 | 37 | - `crates/budctl-cli`: package and binary `budctl`. | |
| 38 | 38 | - `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: | |
| 40 | 40 | - Raw `AF_BLUETOOTH` sockets via `libc`: RFCOMM for the control link, L2CAP to read the buds' SDP record. | |
| 41 | 41 | - `zbus` (blocking API) only to list paired devices. | |
| 42 | 42 | - Supporting BLE GATT would need a new transport kind in core. | |
| 43 | 43 | - 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`. | |
| 45 | 45 | - 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` | |
| 47 | 47 | - cli: `clap`, `serde_json` | |
| 48 | 48 | - gui: `iced`, `serde`, `serde_json`; `iced_test` for tests | |
| 49 | 49 | ||
| @@ -51,7 +51,7 @@ The first supported device is the **realme Buds T310**. More devices are added a | |||
| 51 | 51 | ||
| 52 | 52 | Each module in `crates/budctl-core/src/devices/` defines one `Device` implementation and is listed in `devices::REGISTRY`. A device declares: | |
| 53 | 53 | ||
| 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). | |
| 55 | 55 | - `rfcomm_uuids()`: the transport. SDP turns the UUID into an RFCOMM channel. | |
| 56 | 56 | - `open()`: runs any init sequence the buds need before they accept commands, and returns a `Session`. | |
| 57 | 57 | - The features the model supports. The CLI and GUI only show features the connected device declares. | |
| @@ -110,7 +110,8 @@ Exit codes: | |||
| 110 | 110 | 10. ~~fit test~~ (dropped: the T310 acks it but runs no test) | |
| 111 | 111 | 11. custom EQ | |
| 112 | 112 | 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. | |
| 114 | 115 | ||
| 115 | 116 | ## GUI | |
| 116 | 117 | ||
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | 3 | use std::io::{self, Write}; | |
| 4 | 4 | ||
| 5 | − | use budctl_core::bluez::PairedDevice; | |
| 5 | + | use budctl_core::PairedDevice; | |
| 6 | 6 | use budctl_core::devices::Device; | |
| 7 | 7 | use budctl_core::status::{ | |
| 8 | 8 | Charge, DualDevice, EqPreset, EqPresets, JSON_SCHEMA, NoiseControl, Status, Switch, TouchChange, TouchControls, | |
| @@ -8,9 +8,13 @@ license.workspace = true | |||
| 8 | 8 | publish.workspace = true | |
| 9 | 9 | ||
| 10 | 10 | [dependencies] | |
| 11 | − | libc = "0.2.191" | |
| 12 | 11 | serde = { version = "1.0.229", features = ["derive"] } | |
| 13 | 12 | 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" | |
| 14 | 18 | zbus = { version = "5.19.0", default-features = false, features = ["blocking-api", "async-io"] } | |
| 15 | 19 | ||
| 16 | 20 | [lints] | |
| @@ -10,25 +10,12 @@ use std::os::unix::net::UnixStream; | |||
| 10 | 10 | use zbus::blocking::connection::Builder; | |
| 11 | 11 | use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; | |
| 12 | 12 | ||
| 13 | − | use crate::bt::BdAddr; | |
| 13 | + | use crate::PairedDevice; | |
| 14 | 14 | use crate::error::{Error, Result}; | |
| 15 | 15 | ||
| 16 | 16 | /// The system bus. `DBUS_SYSTEM_BUS_ADDRESS` is deliberately ignored. | |
| 17 | 17 | const SYSTEM_BUS_SOCKET: &str = "/var/run/dbus/system_bus_socket"; | |
| 18 | 18 | ||
| 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 | − | ||
| 32 | 19 | type Properties = HashMap<String, OwnedValue>; | |
| 33 | 20 | type ManagedObjects = HashMap<OwnedObjectPath, HashMap<String, Properties>>; | |
| 34 | 21 | ||
| @@ -5,6 +5,7 @@ use std::str::FromStr; | |||
| 5 | 5 | ||
| 6 | 6 | use serde::{Serialize, Serializer}; | |
| 7 | 7 | ||
| 8 | + | #[cfg(target_os = "linux")] | |
| 8 | 9 | use crate::error::{Error, Result}; | |
| 9 | 10 | ||
| 10 | 11 | /// A Bluetooth device address, kept in the order people write it (`AA:BB:CC:DD:EE:FF`). | |
| @@ -19,6 +20,7 @@ impl BdAddr { | |||
| 19 | 20 | ||
| 20 | 21 | /// The address as the kernel's `bdaddr_t` stores it: reversed. | |
| 21 | 22 | /// Spec: transport.md#sockets | |
| 23 | + | #[cfg(target_os = "linux")] | |
| 22 | 24 | pub(crate) fn kernel_order(self) -> [u8; 6] { | |
| 23 | 25 | let mut bytes = self.0; | |
| 24 | 26 | bytes.reverse(); | |
| @@ -99,6 +101,7 @@ impl Uuid { | |||
| 99 | 101 | ||
| 100 | 102 | /// A UUID as SDP encodes it: 2, 4 or 16 big-endian bytes. | |
| 101 | 103 | /// Spec: transport.md#sdp-data-elements | |
| 104 | + | #[cfg(target_os = "linux")] | |
| 102 | 105 | pub(crate) fn from_sdp(bytes: &[u8]) -> Result<Self> { | |
| 103 | 106 | match *bytes { | |
| 104 | 107 | [a, b] => Ok(Uuid::from_u32(u32::from(u16::from_be_bytes([a, b])))), | |
| @@ -3,7 +3,7 @@ | |||
| 3 | 3 | //! To support a new pair of buds, add one module here and list it in [`REGISTRY`]. Transport, | |
| 4 | 4 | //! CLI and GUI code do not change. | |
| 5 | 5 | ||
| 6 | − | use crate::bluez::PairedDevice; | |
| 6 | + | use crate::PairedDevice; | |
| 7 | 7 | use crate::bt::Uuid; | |
| 8 | 8 | use crate::error::{Error, Result}; | |
| 9 | 9 | use crate::status::{ | |
| @@ -21,7 +21,7 @@ pub trait Device: Sync { | |||
| 21 | 21 | /// Model name, e.g. `realme Buds T310`. | |
| 22 | 22 | fn name(&self) -> &'static str; | |
| 23 | 23 | ||
| 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. | |
| 25 | 25 | fn matches(&self, device: &PairedDevice) -> bool; | |
| 26 | 26 | ||
| 27 | 27 | /// The control service(s), in the order to try. SDP turns each into an RFCOMM channel. | |
| @@ -5,7 +5,7 @@ | |||
| 5 | 5 | ||
| 6 | 6 | use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; | |
| 7 | 7 | ||
| 8 | − | use crate::bluez::PairedDevice; | |
| 8 | + | use crate::PairedDevice; | |
| 9 | 9 | use crate::bt::Uuid; | |
| 10 | 10 | use crate::devices::{Device, Session}; | |
| 11 | 11 | use crate::error::{Error, Kind, Result}; | |
| @@ -1,10 +1,12 @@ | |||
| 1 | 1 | //! budctl-core: control wireless earbuds over Bluetooth, with device support as plugins. | |
| 2 | 2 | //! | |
| 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). | |
| 5 | 6 | //! | |
| 6 | 7 | //! No telemetry: the only sockets are Bluetooth sockets and the local system D-Bus (BlueZ). | |
| 7 | 8 | ||
| 9 | + | #[cfg(target_os = "linux")] | |
| 8 | 10 | pub mod bluez; | |
| 9 | 11 | pub mod bt; | |
| 10 | 12 | pub mod config; | |
| @@ -17,12 +19,28 @@ pub mod transport; | |||
| 17 | 19 | ||
| 18 | 20 | pub use error::{Error, Kind, Result}; | |
| 19 | 21 | ||
| 20 | − | use bluez::PairedDevice; | |
| 21 | 22 | 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")] | |
| 23 | 27 | use transport::rfcomm::Rfcomm; | |
| 28 | + | #[cfg(target_os = "linux")] | |
| 24 | 29 | use transport::{Hexdump, Transport, sdp}; | |
| 25 | 30 | ||
| 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 | + | ||
| 26 | 44 | /// A paired device and the model budctl recognized it as, if any. | |
| 27 | 45 | pub struct Found { | |
| 28 | 46 | pub device: PairedDevice, | |
| @@ -30,6 +48,7 @@ pub struct Found { | |||
| 30 | 48 | } | |
| 31 | 49 | ||
| 32 | 50 | /// Every paired device BlueZ knows, each with its model when budctl supports it. | |
| 51 | + | #[cfg(target_os = "linux")] | |
| 33 | 52 | pub fn scan() -> Result<Vec<Found>> { | |
| 34 | 53 | Ok(bluez::paired_devices()? | |
| 35 | 54 | .into_iter() | |
| @@ -94,6 +113,7 @@ pub fn select(found: Vec<Found>, address: Option<BdAddr>) -> Result<(PairedDevic | |||
| 94 | 113 | ||
| 95 | 114 | /// Open a control session: find the control service with SDP, connect RFCOMM, run the model's | |
| 96 | 115 | /// init sequence. | |
| 116 | + | #[cfg(target_os = "linux")] | |
| 97 | 117 | pub fn connect(device: &PairedDevice, model: &'static dyn Device) -> Result<Box<dyn Session>> { | |
| 98 | 118 | if !device.connected { | |
| 99 | 119 | return Err(Error::NotConnected(format!( | |
| @@ -28,10 +28,13 @@ impl<'a> Reader<'a> { | |||
| 28 | 28 | Ok(u16::from_le_bytes(self.array()?)) | |
| 29 | 29 | } | |
| 30 | 30 | ||
| 31 | + | /// Only SDP, which is Linux-only, reads big-endian numbers so far. | |
| 32 | + | #[cfg(target_os = "linux")] | |
| 31 | 33 | pub(crate) fn u16_be(&mut self) -> Result<u16> { | |
| 32 | 34 | Ok(u16::from_be_bytes(self.array()?)) | |
| 33 | 35 | } | |
| 34 | 36 | ||
| 37 | + | #[cfg(target_os = "linux")] | |
| 35 | 38 | pub(crate) fn u32_be(&mut self) -> Result<u32> { | |
| 36 | 39 | Ok(u32::from_be_bytes(self.array()?)) | |
| 37 | 40 | } | |
| @@ -48,6 +51,8 @@ impl<'a> Reader<'a> { | |||
| 48 | 51 | std::mem::take(&mut self.bytes) | |
| 49 | 52 | } | |
| 50 | 53 | ||
| 54 | + | /// Only SDP checks that it read everything so far. | |
| 55 | + | #[cfg(target_os = "linux")] | |
| 51 | 56 | pub(crate) fn is_empty(&self) -> bool { | |
| 52 | 57 | self.bytes.is_empty() | |
| 53 | 58 | } | |
| @@ -1,14 +1,20 @@ | |||
| 1 | 1 | //! Byte transports. Device protocols only see the [`Transport`] trait, so they can be tested | |
| 2 | 2 | //! against a scripted mock instead of real buds. | |
| 3 | 3 | ||
| 4 | + | #[cfg(target_os = "linux")] | |
| 4 | 5 | use std::io; | |
| 5 | 6 | use std::time::Instant; | |
| 6 | 7 | ||
| 7 | 8 | use crate::debug; | |
| 8 | − | use crate::error::{Error, Result}; | |
| 9 | + | #[cfg(target_os = "linux")] | |
| 10 | + | use crate::error::Error; | |
| 11 | + | use crate::error::Result; | |
| 9 | 12 | ||
| 13 | + | #[cfg(target_os = "linux")] | |
| 10 | 14 | pub mod rfcomm; | |
| 15 | + | #[cfg(target_os = "linux")] | |
| 11 | 16 | pub mod sdp; | |
| 17 | + | #[cfg(target_os = "linux")] | |
| 12 | 18 | mod socket; | |
| 13 | 19 | ||
| 14 | 20 | #[cfg(test)] | |
| @@ -61,6 +67,7 @@ impl Transport for Hexdump { | |||
| 61 | 67 | /// What an OS error on a Bluetooth socket means for budctl. | |
| 62 | 68 | /// `may_be_busy` is set only for the RFCOMM connect, where a refusal means another client | |
| 63 | 69 | /// holds the channel. Spec: transport.md#errno | |
| 70 | + | #[cfg(target_os = "linux")] | |
| 64 | 71 | pub(crate) fn classify(error: io::Error, context: &'static str, may_be_busy: bool) -> Error { | |
| 65 | 72 | let detail = || format!("{context}: {error}"); | |
| 66 | 73 | match error.raw_os_error() { | |
| @@ -80,7 +87,7 @@ pub(crate) fn classify(error: io::Error, context: &'static str, may_be_busy: boo | |||
| 80 | 87 | } | |
| 81 | 88 | } | |
| 82 | 89 | ||
| 83 | − | #[cfg(test)] | |
| 90 | + | #[cfg(all(test, target_os = "linux"))] | |
| 84 | 91 | mod tests { | |
| 85 | 92 | use super::*; | |
| 86 | 93 | use crate::error::Kind; | |
| @@ -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. | |