budctle7b881a
budctl-android: budctl-core for the Android app, over JNI
Step 3 of docs/android.md. libbudctl_android.so exports three native
methods for the app's Core object: start the worker, queue a command,
stop it. The worker calls the app back on its own thread, through a
Platform object, to list the paired devices, to use the RFCOMM link,
and to report events and log lines. Commands and events cross as JSON.
- json.rs: the commands the app sends ({"command": "noise", ...}) and
the events it gets ({"event": "read", ...}). The status is the one
--json writes, but switches keep their labels.
- phone.rs: what the worker needs from the app, as a trait, so the
connect (the same device choice and messages as the CLI's), the link
and the reports are tested without a JVM.
- kotlin.rs: that trait over JNI. Each call gets its own JNI frame.
- budctl-core: Command::Switch takes a String (a key read from JSON);
open() and ensure_connected() come out of connect(), for Android's
connect; debug lines can go somewhere other than stderr.
- Rule 5 gets its second exception, the owner's decision: the exports'
#[unsafe(no_mangle)]. Nothing else in the crate is unsafe.
New dependency: jni 0.22, the JNI bindings. It lets the app call the
library and the library call the app back, with no code generator
(UniFFI's would add a large build-time tree and JNA on the app side).
+1004 −43
14 files changed
- M CLAUDE.md +3 −1
- M Cargo.lock +10 −0
- M Cargo.toml +3 −2
- A crates/budctl-android/Cargo.toml +20 −0
- A crates/budctl-android/src/json.rs +360 −0
- A crates/budctl-android/src/kotlin.rs +151 −0
- A crates/budctl-android/src/lib.rs +114 −0
- A crates/budctl-android/src/phone.rs +257 −0
- M crates/budctl-core/src/debug.rs +31 −3
- M crates/budctl-core/src/lib.rs +28 −17
- M crates/budctl-core/src/transport/mod.rs +1 −1
- M crates/budctl-core/src/worker.rs +2 −2
- M crates/budctl-gui/src/main.rs +7 −4
- M docs/android.md +17 −13
| @@ -28,7 +28,7 @@ The first supported device is the **realme Buds T310**. More devices are added a | |||
| 28 | 28 | 3. **Never guess command bytes.** Every command in a device module must trace back to an anchor in its protocol spec, and the spec must give the fact's evidence level (`realme_t310.md#sources`): seen on a T310, vendor behavior, another model of the family, derived, or verified by us on hardware (`ours-YYYY-MM-DD`). | |
| 29 | 29 | - Put the spec reference in a doc comment on the constant, as `/// Spec: realme_t310.md#anchor`. `tests/guards.rs` checks that each anchor exists. | |
| 30 | 30 | 4. **Write commands are the risky part.** Read-only commands (battery, current ANC mode, firmware version) get implemented and tested first. Write commands are added one at a time and tested on real hardware before the next one. | |
| 31 | − | 5. **No `unwrap()`/`expect()`/`panic!`/indexing outside tests.** Bluetooth fails in many ways, and every failure becomes a typed `budctl_core::Error`. Workspace clippy lints enforce this. `unsafe` is allowed only in `crates/budctl-core/src/transport/socket.rs`. | |
| 31 | + | 5. **No `unwrap()`/`expect()`/`panic!`/indexing outside tests.** Bluetooth fails in many ways, and every failure becomes a typed `budctl_core::Error`. Workspace clippy lints enforce this. `unsafe` is allowed only in `crates/budctl-core/src/transport/socket.rs`, and for the JNI exports' `#[unsafe(no_mangle)]` in `crates/budctl-android/src/lib.rs` (the owner's decision, 2026-11-01). | |
| 32 | 32 | ||
| 33 | 33 | ## Stack | |
| 34 | 34 | ||
| @@ -36,6 +36,7 @@ The first supported device is the **realme Buds T310**. More devices are added a | |||
| 36 | 36 | - `crates/budctl-core`: library with transport, BlueZ, device plugins and the session worker. 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 | + | - `crates/budctl-android`: `libbudctl_android.so` for the Android app, built with `cargo ndk`: three JNI exports, JSON commands and events, and a `Transport` over the app's Bluetooth link (`docs/android.md#boundary`). | |
| 39 | 40 | - On Linux, Bluetooth goes through BlueZ: | |
| 40 | 41 | - Raw `AF_BLUETOOTH` sockets via `libc`: RFCOMM for the control link, L2CAP to read the buds' SDP record. | |
| 41 | 42 | - `zbus` (blocking API) only to list paired devices. | |
| @@ -46,6 +47,7 @@ The first supported device is the **realme Buds T310**. More devices are added a | |||
| 46 | 47 | - core: `serde`, `serde_json` (config file); on Linux only, `libc` and `zbus` | |
| 47 | 48 | - cli: `clap`, `serde_json` | |
| 48 | 49 | - gui: `iced`, `serde`, `serde_json`; `iced_test` for tests | |
| 50 | + | - android: `jni`, `serde`, `serde_json` | |
| 49 | 51 | ||
| 50 | 52 | ## Device plugin contract | |
| 51 | 53 | ||
| @@ -334,6 +334,16 @@ dependencies = [ | |||
| 334 | 334 | "serde_json", | |
| 335 | 335 | ] | |
| 336 | 336 | ||
| 337 | + | [[package]] | |
| 338 | + | name = "budctl-android" | |
| 339 | + | version = "0.1.3" | |
| 340 | + | dependencies = [ | |
| 341 | + | "budctl-core", | |
| 342 | + | "jni", | |
| 343 | + | "serde", | |
| 344 | + | "serde_json", | |
| 345 | + | ] | |
| 346 | + | ||
| 337 | 347 | [[package]] | |
| 338 | 348 | name = "budctl-core" | |
| 339 | 349 | version = "0.1.3" | |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [workspace] | |
| 2 | 2 | resolver = "3" | |
| 3 | − | members = ["crates/budctl-core", "crates/budctl-cli", "crates/budctl-gui"] | |
| 3 | + | members = ["crates/budctl-core", "crates/budctl-cli", "crates/budctl-gui", "crates/budctl-android"] | |
| 4 | 4 | ||
| 5 | 5 | [workspace.package] | |
| 6 | 6 | version = "0.1.3" | |
| @@ -10,7 +10,8 @@ license = "GPL-3.0-or-later" | |||
| 10 | 10 | publish = false | |
| 11 | 11 | ||
| 12 | 12 | [workspace.lints.rust] | |
| 13 | − | # The only exception is crates/budctl-core/src/transport/socket.rs (Bluetooth socket syscalls). | |
| 13 | + | # The exceptions: crates/budctl-core/src/transport/socket.rs (Bluetooth socket syscalls) and the | |
| 14 | + | # JNI exports' #[unsafe(no_mangle)] in crates/budctl-android/src/lib.rs. | |
| 14 | 15 | unsafe_code = "deny" | |
| 15 | 16 | ||
| 16 | 17 | [workspace.lints.clippy] | |
| @@ -0,0 +1,20 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "budctl-android" | |
| 3 | + | description = "budctl-core for the Android app, over JNI" | |
| 4 | + | version.workspace = true | |
| 5 | + | edition.workspace = true | |
| 6 | + | rust-version.workspace = true | |
| 7 | + | license.workspace = true | |
| 8 | + | publish.workspace = true | |
| 9 | + | ||
| 10 | + | [lib] | |
| 11 | + | crate-type = ["cdylib"] | |
| 12 | + | ||
| 13 | + | [dependencies] | |
| 14 | + | budctl-core = { path = "../budctl-core" } | |
| 15 | + | jni = "0.22.4" | |
| 16 | + | serde = { version = "1.0.229", features = ["derive"] } | |
| 17 | + | serde_json = "1.0.154" | |
| 18 | + | ||
| 19 | + | [lints] | |
| 20 | + | workspace = true | |
| @@ -0,0 +1,360 @@ | |||
| 1 | + | //! The JSON between the app's Kotlin side and the worker (docs/android.md#boundary). | |
| 2 | + | //! | |
| 3 | + | //! Commands come in as `{"command": "noise", "mode": "anc", "level": "deep"}`, and events go out | |
| 4 | + | //! as `{"event": "noise", "mode": "anc", "level": "deep"}`. The values are the status types the | |
| 5 | + | //! CLI's `--json` writes, except that switches keep their labels. | |
| 6 | + | ||
| 7 | + | use budctl_core::status::{AncLevel, EqBand, NoiseMode, Status}; | |
| 8 | + | use budctl_core::worker::{Command, Event}; | |
| 9 | + | use budctl_core::{Error, Kind, PairedDevice, Result}; | |
| 10 | + | use serde::Deserialize; | |
| 11 | + | use serde_json::{Map, Value, to_value}; | |
| 12 | + | ||
| 13 | + | use crate::phone::app_error; | |
| 14 | + | ||
| 15 | + | /// A command, as the app sends it. | |
| 16 | + | #[derive(Debug, Deserialize)] | |
| 17 | + | #[serde(tag = "command", rename_all = "kebab-case")] | |
| 18 | + | enum Request { | |
| 19 | + | Read, | |
| 20 | + | /// `off`, `transparency` or `anc`; for `anc`, `light`, `medium` or `deep` (the default). | |
| 21 | + | Noise { | |
| 22 | + | mode: String, | |
| 23 | + | level: Option<String>, | |
| 24 | + | }, | |
| 25 | + | Switch { | |
| 26 | + | key: String, | |
| 27 | + | on: bool, | |
| 28 | + | }, | |
| 29 | + | Eq { | |
| 30 | + | preset: String, | |
| 31 | + | }, | |
| 32 | + | CustomEq { | |
| 33 | + | preset: String, | |
| 34 | + | bands: Vec<Band>, | |
| 35 | + | }, | |
| 36 | + | Touch { | |
| 37 | + | side: String, | |
| 38 | + | gesture: String, | |
| 39 | + | action: String, | |
| 40 | + | }, | |
| 41 | + | HoldCycle { | |
| 42 | + | modes: Vec<String>, | |
| 43 | + | }, | |
| 44 | + | Find { | |
| 45 | + | ring: bool, | |
| 46 | + | }, | |
| 47 | + | Bass { | |
| 48 | + | level: i8, | |
| 49 | + | }, | |
| 50 | + | CreateEq { | |
| 51 | + | name: String, | |
| 52 | + | }, | |
| 53 | + | RenameEq { | |
| 54 | + | preset: String, | |
| 55 | + | name: String, | |
| 56 | + | }, | |
| 57 | + | DeleteEq { | |
| 58 | + | preset: String, | |
| 59 | + | }, | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | #[derive(Debug, Deserialize)] | |
| 63 | + | struct Band { | |
| 64 | + | hz: u16, | |
| 65 | + | gain: i8, | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | /// The worker command for a JSON command from the app. | |
| 69 | + | pub fn command(json: &str) -> Result<Command> { | |
| 70 | + | let request: Request = | |
| 71 | + | serde_json::from_str(json).map_err(|e| Error::Usage(format!("not a command budctl knows: {e}")))?; | |
| 72 | + | Ok(match request { | |
| 73 | + | Request::Read => Command::Read, | |
| 74 | + | Request::Noise { mode, level } => Command::Noise(noise_mode(&mode, level.as_deref())?), | |
| 75 | + | Request::Switch { key, on } => Command::Switch(key, on), | |
| 76 | + | Request::Eq { preset } => Command::Eq(preset), | |
| 77 | + | Request::CustomEq { preset, bands } => Command::CustomEq( | |
| 78 | + | preset, | |
| 79 | + | bands | |
| 80 | + | .into_iter() | |
| 81 | + | .map(|band| EqBand { | |
| 82 | + | hz: band.hz, | |
| 83 | + | gain: band.gain, | |
| 84 | + | }) | |
| 85 | + | .collect(), | |
| 86 | + | ), | |
| 87 | + | Request::Touch { side, gesture, action } => Command::Touch { side, gesture, action }, | |
| 88 | + | Request::HoldCycle { modes } => Command::HoldCycle(modes), | |
| 89 | + | Request::Find { ring } => Command::Find(ring), | |
| 90 | + | Request::Bass { level } => Command::Bass(level), | |
| 91 | + | Request::CreateEq { name } => Command::CreateEq(name), | |
| 92 | + | Request::RenameEq { preset, name } => Command::RenameEq(preset, name), | |
| 93 | + | Request::DeleteEq { preset } => Command::DeleteEq(preset), | |
| 94 | + | }) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /// `anc` with no level is the deepest, as `budctl anc on` is. | |
| 98 | + | fn noise_mode(mode: &str, level: Option<&str>) -> Result<NoiseMode> { | |
| 99 | + | match (mode, level) { | |
| 100 | + | ("off", None) => Ok(NoiseMode::Off), | |
| 101 | + | ("transparency", None) => Ok(NoiseMode::Transparency), | |
| 102 | + | ("anc", None | Some("deep")) => Ok(NoiseMode::Anc(AncLevel::Deep)), | |
| 103 | + | ("anc", Some("medium")) => Ok(NoiseMode::Anc(AncLevel::Medium)), | |
| 104 | + | ("anc", Some("light")) => Ok(NoiseMode::Anc(AncLevel::Light)), | |
| 105 | + | _ => Err(Error::Usage(format!( | |
| 106 | + | "no noise-control mode {mode:?} with level {level:?}" | |
| 107 | + | ))), | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /// A worker event, as the app reads it. | |
| 112 | + | pub fn event(event: &Event) -> serde_json::Result<String> { | |
| 113 | + | let (name, fields): (&str, Vec<(&str, Value)>) = match event { | |
| 114 | + | Event::Found(name) => ("found", vec![("name", to_value(name)?)]), | |
| 115 | + | Event::Read(reading) => ( | |
| 116 | + | "read", | |
| 117 | + | vec![ | |
| 118 | + | ("status", status(&reading.status)?), | |
| 119 | + | ("eq", to_value(&reading.eq)?), | |
| 120 | + | ("touch", to_value(&reading.touch)?), | |
| 121 | + | ("bass_range", to_value(reading.bass_range)?), | |
| 122 | + | ("dual", to_value(&reading.dual)?), | |
| 123 | + | ], | |
| 124 | + | ), | |
| 125 | + | Event::Noise(now) => ( | |
| 126 | + | "noise", | |
| 127 | + | vec![("mode", to_value(&now.mode)?), ("level", to_value(&now.level)?)], | |
| 128 | + | ), | |
| 129 | + | Event::Switch(switch) => ("switch", vec![("switch", to_value(switch)?)]), | |
| 130 | + | Event::Eq(preset) => ("eq", vec![("preset", to_value(preset)?)]), | |
| 131 | + | Event::CustomEq(preset) => ("custom-eq", vec![("preset", to_value(preset)?)]), | |
| 132 | + | Event::Touch(gestures) => ("touch", vec![("gestures", to_value(gestures)?)]), | |
| 133 | + | Event::HoldCycle(modes) => ("hold-cycle", vec![("modes", to_value(modes)?)]), | |
| 134 | + | Event::Ringing(ring) => ("ringing", vec![("ring", Value::from(*ring))]), | |
| 135 | + | Event::Bass(level) => ("bass", vec![("level", Value::from(*level))]), | |
| 136 | + | Event::Presets(eq) => ("presets", vec![("eq", to_value(eq)?)]), | |
| 137 | + | Event::Failed { kind, message } => ( | |
| 138 | + | "failed", | |
| 139 | + | vec![("kind", Value::from(kind_name(*kind))), ("message", to_value(message)?)], | |
| 140 | + | ), | |
| 141 | + | Event::Lost(message) => ("lost", vec![("message", to_value(message)?)]), | |
| 142 | + | }; | |
| 143 | + | let mut object = Map::new(); | |
| 144 | + | object.insert("event".to_owned(), Value::from(name)); | |
| 145 | + | for (key, value) in fields { | |
| 146 | + | object.insert(key.to_owned(), value); | |
| 147 | + | } | |
| 148 | + | serde_json::to_string(&object) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | /// The status as `--json` writes it, except the switches: `--json` writes them as | |
| 152 | + | /// `{"game": false}`, and the app needs their labels too. | |
| 153 | + | fn status(status: &Status) -> serde_json::Result<Value> { | |
| 154 | + | let mut value = to_value(status)?; | |
| 155 | + | if let Some(object) = value.as_object_mut() { | |
| 156 | + | object.insert("switches".to_owned(), to_value(&status.switches)?); | |
| 157 | + | } | |
| 158 | + | Ok(value) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | fn kind_name(kind: Kind) -> &'static str { | |
| 162 | + | match kind { | |
| 163 | + | Kind::Usage => "usage", | |
| 164 | + | Kind::NotFound => "not-found", | |
| 165 | + | Kind::Busy => "busy", | |
| 166 | + | Kind::Failed => "failed", | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | /// A paired device, as the app lists it. | |
| 171 | + | #[derive(Debug, Deserialize)] | |
| 172 | + | struct Paired { | |
| 173 | + | address: String, | |
| 174 | + | name: String, | |
| 175 | + | alias: String, | |
| 176 | + | connected: bool, | |
| 177 | + | /// Service UUIDs, lowercase and hyphenated, as Android cached them from the buds' SDP record. | |
| 178 | + | uuids: Vec<String>, | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | /// The paired devices, from the app's list. | |
| 182 | + | pub fn paired_devices(json: &str) -> Result<Vec<PairedDevice>> { | |
| 183 | + | let list: Vec<Paired> = serde_json::from_str(json).map_err(|e| app_error("reading the app's device list", e))?; | |
| 184 | + | list.into_iter() | |
| 185 | + | .map(|paired| { | |
| 186 | + | Ok(PairedDevice { | |
| 187 | + | address: paired | |
| 188 | + | .address | |
| 189 | + | .parse() | |
| 190 | + | .map_err(|e| app_error("reading the app's device list", e))?, | |
| 191 | + | name: paired.name, | |
| 192 | + | alias: paired.alias, | |
| 193 | + | connected: paired.connected, | |
| 194 | + | uuids: paired.uuids, | |
| 195 | + | }) | |
| 196 | + | }) | |
| 197 | + | .collect() | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | /// Why the app could not open the link. | |
| 201 | + | #[derive(Debug, Deserialize)] | |
| 202 | + | struct Failure { | |
| 203 | + | /// `busy`, `not-connected`, `timeout` or `failed`. | |
| 204 | + | kind: String, | |
| 205 | + | message: String, | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | /// The error for the app's account of a failed connect (docs/android.md#bluetooth). | |
| 209 | + | pub fn connect_failure(json: &str) -> Error { | |
| 210 | + | const CONTEXT: &str = "connecting to the control channel"; | |
| 211 | + | match serde_json::from_str::<Failure>(json) { | |
| 212 | + | Ok(failure) => match failure.kind.as_str() { | |
| 213 | + | "busy" => Error::ChannelBusy(failure.message), | |
| 214 | + | "not-connected" => Error::NotConnected(failure.message), | |
| 215 | + | "timeout" => Error::Timeout(failure.message), | |
| 216 | + | _ => app_error(CONTEXT, failure.message), | |
| 217 | + | }, | |
| 218 | + | Err(e) => app_error(CONTEXT, e), | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | #[cfg(test)] | |
| 223 | + | mod tests { | |
| 224 | + | use super::*; | |
| 225 | + | use budctl_core::status::{NoiseControl, Switch}; | |
| 226 | + | use budctl_core::worker::Reading; | |
| 227 | + | ||
| 228 | + | #[test] | |
| 229 | + | fn commands_parse_into_worker_commands() { | |
| 230 | + | assert!(matches!(command(r#"{"command": "read"}"#), Ok(Command::Read))); | |
| 231 | + | assert!(matches!( | |
| 232 | + | command(r#"{"command": "noise", "mode": "anc"}"#), | |
| 233 | + | Ok(Command::Noise(NoiseMode::Anc(AncLevel::Deep))) | |
| 234 | + | )); | |
| 235 | + | assert!(matches!( | |
| 236 | + | command(r#"{"command": "noise", "mode": "anc", "level": "light"}"#), | |
| 237 | + | Ok(Command::Noise(NoiseMode::Anc(AncLevel::Light))) | |
| 238 | + | )); | |
| 239 | + | assert!(matches!( | |
| 240 | + | command(r#"{"command": "switch", "key": "game", "on": true}"#), | |
| 241 | + | Ok(Command::Switch(key, true)) if key == "game" | |
| 242 | + | )); | |
| 243 | + | let Ok(Command::CustomEq(preset, bands)) = | |
| 244 | + | command(r#"{"command": "custom-eq", "preset": "custom-4", "bands": [{"hz": 50, "gain": -3}]}"#) | |
| 245 | + | else { | |
| 246 | + | panic!("a custom EQ command expected"); | |
| 247 | + | }; | |
| 248 | + | assert_eq!(preset, "custom-4"); | |
| 249 | + | assert_eq!(bands, [EqBand { hz: 50, gain: -3 }]); | |
| 250 | + | assert!(matches!( | |
| 251 | + | command(r#"{"command": "hold-cycle", "modes": ["off", "anc"]}"#), | |
| 252 | + | Ok(Command::HoldCycle(modes)) if modes == ["off", "anc"] | |
| 253 | + | )); | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | #[test] | |
| 257 | + | fn bad_commands_are_usage_errors() { | |
| 258 | + | for json in [ | |
| 259 | + | r#"{"command": "reboot"}"#, | |
| 260 | + | r#"{"command": "noise", "mode": "loud"}"#, | |
| 261 | + | r#"{"command": "noise", "mode": "off", "level": "deep"}"#, | |
| 262 | + | r#"{"command": "bass", "level": 300}"#, | |
| 263 | + | "not json", | |
| 264 | + | ] { | |
| 265 | + | assert_eq!(command(json).err().map(|e| e.kind()), Some(Kind::Usage), "{json}"); | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | fn parse(event: &Event) -> Value { | |
| 270 | + | serde_json::from_str(&super::event(event).unwrap()).unwrap() | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | #[test] | |
| 274 | + | fn events_are_tagged_objects() { | |
| 275 | + | let noise = parse(&Event::Noise(NoiseControl { | |
| 276 | + | mode: "anc".to_owned(), | |
| 277 | + | level: Some("deep".to_owned()), | |
| 278 | + | })); | |
| 279 | + | assert_eq!( | |
| 280 | + | noise, | |
| 281 | + | serde_json::json!({"event": "noise", "mode": "anc", "level": "deep"}) | |
| 282 | + | ); | |
| 283 | + | ||
| 284 | + | let failed = parse(&Event::Failed { | |
| 285 | + | kind: Kind::Busy, | |
| 286 | + | message: "control channel busy".to_owned(), | |
| 287 | + | }); | |
| 288 | + | assert_eq!( | |
| 289 | + | failed, | |
| 290 | + | serde_json::json!({"event": "failed", "kind": "busy", "message": "control channel busy"}) | |
| 291 | + | ); | |
| 292 | + | assert_eq!( | |
| 293 | + | parse(&Event::Ringing(true)), | |
| 294 | + | serde_json::json!({"event": "ringing", "ring": true}) | |
| 295 | + | ); | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | #[test] | |
| 299 | + | fn a_reading_keeps_the_switch_labels() { | |
| 300 | + | let game = Switch { | |
| 301 | + | key: "game", | |
| 302 | + | label: "Game mode", | |
| 303 | + | on: true, | |
| 304 | + | }; | |
| 305 | + | let reading = Reading { | |
| 306 | + | status: Status { | |
| 307 | + | switches: vec![game], | |
| 308 | + | ..Status::default() | |
| 309 | + | }, | |
| 310 | + | eq: None, | |
| 311 | + | touch: None, | |
| 312 | + | bass_range: Some((-5, 5)), | |
| 313 | + | dual: None, | |
| 314 | + | }; | |
| 315 | + | let read = parse(&Event::Read(Box::new(reading))); | |
| 316 | + | assert_eq!(read["event"], "read"); | |
| 317 | + | assert_eq!( | |
| 318 | + | read["status"]["switches"], | |
| 319 | + | serde_json::json!([{"key": "game", "label": "Game mode", "on": true}]) | |
| 320 | + | ); | |
| 321 | + | assert_eq!(read["bass_range"], serde_json::json!([-5, 5])); | |
| 322 | + | assert_eq!(read["eq"], Value::Null); | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | #[test] | |
| 326 | + | fn paired_devices_come_from_the_app_list() { | |
| 327 | + | let list = paired_devices( | |
| 328 | + | r#"[{"address": "00:11:22:aa:bb:cc", "name": "realme Buds T310", "alias": "Buds", | |
| 329 | + | "connected": true, "uuids": ["0000079a-d102-11e1-9b23-00025b00a5a5"]}]"#, | |
| 330 | + | ) | |
| 331 | + | .unwrap(); | |
| 332 | + | let [buds] = list.as_slice() else { | |
| 333 | + | panic!("one device expected"); | |
| 334 | + | }; | |
| 335 | + | assert_eq!(buds.address.to_string(), "00:11:22:AA:BB:CC"); | |
| 336 | + | assert_eq!(buds.alias, "Buds"); | |
| 337 | + | assert!(buds.connected); | |
| 338 | + | ||
| 339 | + | assert!( | |
| 340 | + | paired_devices(r#"[{"address": "nope", "name": "", "alias": "", "connected": false, "uuids": []}]"#) | |
| 341 | + | .is_err() | |
| 342 | + | ); | |
| 343 | + | assert!(paired_devices("{}").is_err()); | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | #[test] | |
| 347 | + | fn connect_failures_keep_their_kind() { | |
| 348 | + | let kind = |json: &str| connect_failure(json).kind(); | |
| 349 | + | assert_eq!(kind(r#"{"kind": "busy", "message": "refused"}"#), Kind::Busy); | |
| 350 | + | assert_eq!(kind(r#"{"kind": "not-connected", "message": "off"}"#), Kind::NotFound); | |
| 351 | + | assert_eq!(kind(r#"{"kind": "timeout", "message": "8 s"}"#), Kind::Failed); | |
| 352 | + | assert_eq!(kind(r#"{"kind": "failed", "message": "?"}"#), Kind::NotFound); | |
| 353 | + | assert_eq!(kind("garbage"), Kind::NotFound); | |
| 354 | + | assert!( | |
| 355 | + | connect_failure(r#"{"kind": "busy", "message": "refused"}"#) | |
| 356 | + | .to_string() | |
| 357 | + | .contains("is another app connected?") | |
| 358 | + | ); | |
| 359 | + | } | |
| 360 | + | } | |
| @@ -0,0 +1,151 @@ | |||
| 1 | + | //! [`Phone`] over JNI: calls into the app's `Platform` object (`Platform.kt`) from the worker's | |
| 2 | + | //! thread. The thread attaches to the VM on its first call and stays attached until it ends; | |
| 3 | + | //! each call gets a JNI frame of its own, so no local reference outlives it. | |
| 4 | + | ||
| 5 | + | use std::time::Duration; | |
| 6 | + | ||
| 7 | + | use budctl_core::{Error, Result}; | |
| 8 | + | use jni::objects::{JByteArray, JObject, JString}; | |
| 9 | + | use jni::refs::Global; | |
| 10 | + | use jni::strings::JNIStr; | |
| 11 | + | use jni::vm::JavaVM; | |
| 12 | + | use jni::{Env, JValue, jni_sig, jni_str}; | |
| 13 | + | ||
| 14 | + | use crate::json; | |
| 15 | + | use crate::phone::{Phone, app_error}; | |
| 16 | + | ||
| 17 | + | pub struct Kotlin { | |
| 18 | + | platform: Global<JObject<'static>>, | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | impl Kotlin { | |
| 22 | + | pub fn new(platform: Global<JObject<'static>>) -> Self { | |
| 23 | + | Kotlin { platform } | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | /// Runs `call` on this thread's JNI environment. A Java exception or a JNI failure becomes an | |
| 27 | + | /// app error about `what`. | |
| 28 | + | fn call<T>( | |
| 29 | + | &self, | |
| 30 | + | what: &'static str, | |
| 31 | + | call: impl FnOnce(&mut Env, &JObject<'static>) -> jni::errors::Result<T>, | |
| 32 | + | ) -> Result<T> { | |
| 33 | + | let vm = JavaVM::singleton().map_err(|e| app_error(what, e))?; | |
| 34 | + | vm.attach_current_thread(|env| call(env, &self.platform)) | |
| 35 | + | .map_err(|e| app_error(what, e)) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /// Calls a `(String) -> Unit` method, for reports: a failure has nowhere to go. | |
| 39 | + | fn tell(&self, method: &'static JNIStr, text: &str) { | |
| 40 | + | let _ = self.call("reporting to the app", |env, platform| { | |
| 41 | + | let text = env.new_string(text)?; | |
| 42 | + | env.call_method( | |
| 43 | + | platform, | |
| 44 | + | method, | |
| 45 | + | jni_sig!((text: java.lang.String) -> void), | |
| 46 | + | &[JValue::Object(&text)], | |
| 47 | + | )? | |
| 48 | + | .v() | |
| 49 | + | }); | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /// A Kotlin `String?` as a Rust string. | |
| 54 | + | fn string(env: &mut Env, object: JObject) -> jni::errors::Result<Option<String>> { | |
| 55 | + | if object.is_null() { | |
| 56 | + | return Ok(None); | |
| 57 | + | } | |
| 58 | + | let string = env.cast_local::<JString>(object)?; | |
| 59 | + | Ok(Some(string.try_to_string(env)?)) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | impl Phone for Kotlin { | |
| 63 | + | fn paired_devices(&self) -> Result<String> { | |
| 64 | + | let list = self.call("listing the paired devices", |env, platform| { | |
| 65 | + | let list = env | |
| 66 | + | .call_method( | |
| 67 | + | platform, | |
| 68 | + | jni_str!("pairedDevices"), | |
| 69 | + | jni_sig!(() -> java.lang.String), | |
| 70 | + | &[], | |
| 71 | + | )? | |
| 72 | + | .l()?; | |
| 73 | + | string(env, list) | |
| 74 | + | })?; | |
| 75 | + | Ok(list.unwrap_or_default()) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | fn connect(&self, address: &str, service: &str) -> Result<()> { | |
| 79 | + | let failure = self.call("connecting to the control channel", |env, platform| { | |
| 80 | + | let address = env.new_string(address)?; | |
| 81 | + | let service = env.new_string(service)?; | |
| 82 | + | let failure = env | |
| 83 | + | .call_method( | |
| 84 | + | platform, | |
| 85 | + | jni_str!("connect"), | |
| 86 | + | jni_sig!((address: java.lang.String, service: java.lang.String) -> java.lang.String), | |
| 87 | + | &[JValue::Object(&address), JValue::Object(&service)], | |
| 88 | + | )? | |
| 89 | + | .l()?; | |
| 90 | + | string(env, failure) | |
| 91 | + | })?; | |
| 92 | + | match failure { | |
| 93 | + | None => Ok(()), | |
| 94 | + | Some(json) => Err(json::connect_failure(&json)), | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | fn send(&self, bytes: &[u8]) -> Result<()> { | |
| 99 | + | let failure = self.call("sending to the buds", |env, platform| { | |
| 100 | + | let bytes = env.byte_array_from_slice(bytes)?; | |
| 101 | + | let failure = env | |
| 102 | + | .call_method( | |
| 103 | + | platform, | |
| 104 | + | jni_str!("send"), | |
| 105 | + | jni_sig!((bytes: jbyte[]) -> java.lang.String), | |
| 106 | + | &[JValue::Object(&bytes)], | |
| 107 | + | )? | |
| 108 | + | .l()?; | |
| 109 | + | string(env, failure) | |
| 110 | + | })?; | |
| 111 | + | match failure { | |
| 112 | + | None => Ok(()), | |
| 113 | + | Some(message) => Err(Error::NotConnected(format!("sending to the buds: {message}"))), | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | fn recv(&self, timeout: Duration) -> Result<Option<Vec<u8>>> { | |
| 118 | + | // Rounded up, so a wait shorter than a millisecond still waits. | |
| 119 | + | let millis = i64::try_from(timeout.as_micros().div_ceil(1000)).unwrap_or(i64::MAX); | |
| 120 | + | self.call("reading from the buds", |env, platform| { | |
| 121 | + | let bytes = env | |
| 122 | + | .call_method( | |
| 123 | + | platform, | |
| 124 | + | jni_str!("recv"), | |
| 125 | + | jni_sig!((timeout_ms: jlong) -> jbyte[]), | |
| 126 | + | &[JValue::Long(millis)], | |
| 127 | + | )? | |
| 128 | + | .l()?; | |
| 129 | + | if bytes.is_null() { | |
| 130 | + | return Ok(None); | |
| 131 | + | } | |
| 132 | + | let bytes = env.cast_local::<JByteArray>(bytes)?; | |
| 133 | + | Ok(Some(env.convert_byte_array(&bytes)?)) | |
| 134 | + | }) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | fn close(&self) { | |
| 138 | + | let _ = self.call("closing the link", |env, platform| { | |
| 139 | + | env.call_method(platform, jni_str!("close"), jni_sig!(() -> void), &[])? | |
| 140 | + | .v() | |
| 141 | + | }); | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | fn event(&self, json: &str) { | |
| 145 | + | self.tell(jni_str!("event"), json); | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | fn log(&self, line: &str) { | |
| 149 | + | self.tell(jni_str!("log"), line); | |
| 150 | + | } | |
| 151 | + | } | |
| @@ -0,0 +1,114 @@ | |||
| 1 | + | //! budctl-core inside the Android app (docs/android.md). | |
| 2 | + | //! | |
| 3 | + | //! The app's `Core` object calls three functions: `start` the worker, queue a `command`, and | |
| 4 | + | //! `stop` it. The worker calls the app back on its own thread, through the `Platform` object | |
| 5 | + | //! `start` was given (`kotlin.rs`): to list the paired devices, to use the RFCOMM link, and to | |
| 6 | + | //! report events and log lines. Commands and events cross as JSON (`json.rs`). Nothing here knows | |
| 7 | + | //! the buds' protocol. | |
| 8 | + | //! | |
| 9 | + | //! The exports are the second exception to rule 5 (CLAUDE.md): the VM finds a native method by | |
| 10 | + | //! its exported name, and exporting one is an unsafe attribute. Nothing else here is unsafe. | |
| 11 | + | ||
| 12 | + | mod json; | |
| 13 | + | mod kotlin; | |
| 14 | + | mod phone; | |
| 15 | + | ||
| 16 | + | use std::sync::mpsc::Sender; | |
| 17 | + | use std::sync::{Arc, Mutex, PoisonError}; | |
| 18 | + | use std::time::Duration; | |
| 19 | + | ||
| 20 | + | use budctl_core::debug; | |
| 21 | + | use budctl_core::worker::{self, Command}; | |
| 22 | + | use jni::errors::ThrowRuntimeExAndDefault; | |
| 23 | + | use jni::objects::{JClass, JObject, JString}; | |
| 24 | + | use jni::sys::{jboolean, jlong}; | |
| 25 | + | use jni::{Env, EnvUnowned}; | |
| 26 | + | ||
| 27 | + | use kotlin::Kotlin; | |
| 28 | + | use phone::{Phone, Reporter}; | |
| 29 | + | ||
| 30 | + | /// The running worker's inbox. Dropping it ends the worker, which closes any session. | |
| 31 | + | static WORKER: Mutex<Option<Sender<Command>>> = Mutex::new(None); | |
| 32 | + | ||
| 33 | + | /// `Core.start(platform, idleMs, debug)`: starts the worker, in place of any running one. Returns | |
| 34 | + | /// null, or why it could not start. | |
| 35 | + | #[allow(unsafe_code)] | |
| 36 | + | #[unsafe(no_mangle)] | |
| 37 | + | pub extern "system" fn Java_io_github_krolyxon_budctl_Core_start<'caller>( | |
| 38 | + | mut unowned: EnvUnowned<'caller>, | |
| 39 | + | _class: JClass<'caller>, | |
| 40 | + | platform: JObject<'caller>, | |
| 41 | + | idle_ms: jlong, | |
| 42 | + | debug: jboolean, | |
| 43 | + | ) -> JString<'caller> { | |
| 44 | + | unowned | |
| 45 | + | .with_env(|env| -> jni::errors::Result<_> { | |
| 46 | + | let phone: Arc<dyn Phone> = Arc::new(Kotlin::new(env.new_global_ref(&platform)?)); | |
| 47 | + | if debug { | |
| 48 | + | debug::enable(); | |
| 49 | + | } else { | |
| 50 | + | debug::disable(); | |
| 51 | + | } | |
| 52 | + | let output = phone.clone(); | |
| 53 | + | debug::set_output(Box::new(move |line| output.log(line))); | |
| 54 | + | ||
| 55 | + | let idle = Duration::from_millis(u64::try_from(idle_ms).unwrap_or(0)); | |
| 56 | + | let connect = phone::connect(phone.clone()); | |
| 57 | + | let failure = match worker::start(connect, Box::new(Reporter(phone)), idle) { | |
| 58 | + | Ok(inbox) => { | |
| 59 | + | *WORKER.lock().unwrap_or_else(PoisonError::into_inner) = Some(inbox); | |
| 60 | + | None | |
| 61 | + | } | |
| 62 | + | Err(e) => Some(format!("could not start the Bluetooth thread: {e}")), | |
| 63 | + | }; | |
| 64 | + | answer(env, failure) | |
| 65 | + | }) | |
| 66 | + | .resolve::<ThrowRuntimeExAndDefault>() | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /// `Core.command(json)`: queues one command for the worker; its event comes back through | |
| 70 | + | /// `Platform.event`. Returns null, or why the command was not queued. | |
| 71 | + | #[allow(unsafe_code)] | |
| 72 | + | #[unsafe(no_mangle)] | |
| 73 | + | pub extern "system" fn Java_io_github_krolyxon_budctl_Core_command<'caller>( | |
| 74 | + | mut unowned: EnvUnowned<'caller>, | |
| 75 | + | _class: JClass<'caller>, | |
| 76 | + | json: JString<'caller>, | |
| 77 | + | ) -> JString<'caller> { | |
| 78 | + | unowned | |
| 79 | + | .with_env(|env| -> jni::errors::Result<_> { | |
| 80 | + | let failure = match json::command(&json.try_to_string(env)?) { | |
| 81 | + | Ok(command) => queue(command).err(), | |
| 82 | + | Err(e) => Some(e.to_string()), | |
| 83 | + | }; | |
| 84 | + | answer(env, failure) | |
| 85 | + | }) | |
| 86 | + | .resolve::<ThrowRuntimeExAndDefault>() | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /// `Core.stop()`: ends the worker, which closes any session once the command at hand is done. | |
| 90 | + | #[allow(unsafe_code)] | |
| 91 | + | #[unsafe(no_mangle)] | |
| 92 | + | pub extern "system" fn Java_io_github_krolyxon_budctl_Core_stop<'caller>( | |
| 93 | + | _unowned: EnvUnowned<'caller>, | |
| 94 | + | _class: JClass<'caller>, | |
| 95 | + | ) { | |
| 96 | + | let inbox = WORKER.lock().unwrap_or_else(PoisonError::into_inner).take(); | |
| 97 | + | drop(inbox); | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | fn queue(command: Command) -> Result<(), String> { | |
| 101 | + | let worker = WORKER.lock().unwrap_or_else(PoisonError::into_inner); | |
| 102 | + | let inbox = worker.as_ref().ok_or("the Bluetooth thread is not running")?; | |
| 103 | + | inbox | |
| 104 | + | .send(command) | |
| 105 | + | .map_err(|_| "the Bluetooth thread has stopped".to_owned()) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | /// Null for no failure, else the message, as a Java string. | |
| 109 | + | fn answer<'local>(env: &mut Env<'local>, failure: Option<String>) -> jni::errors::Result<JString<'local>> { | |
| 110 | + | match failure { | |
| 111 | + | None => Ok(JString::null()), | |
| 112 | + | Some(message) => env.new_string(message), | |
| 113 | + | } | |
| 114 | + | } | |
| @@ -0,0 +1,257 @@ | |||
| 1 | + | //! The app's side of the worker, as the worker sees it: the paired devices, the RFCOMM link, and | |
| 2 | + | //! where events and log lines go. `kotlin.rs` provides it over JNI; the tests fake it. | |
| 3 | + | ||
| 4 | + | use std::fmt::Display; | |
| 5 | + | use std::io; | |
| 6 | + | use std::sync::Arc; | |
| 7 | + | use std::time::{Duration, Instant}; | |
| 8 | + | ||
| 9 | + | use budctl_core::devices; | |
| 10 | + | use budctl_core::transport::Transport; | |
| 11 | + | use budctl_core::worker::{Connect, Event, Report}; | |
| 12 | + | use budctl_core::{Error, Found, Result}; | |
| 13 | + | ||
| 14 | + | use crate::json; | |
| 15 | + | ||
| 16 | + | /// What the worker needs from the app. Every method is called on the worker's thread. | |
| 17 | + | pub trait Phone: Send + Sync { | |
| 18 | + | /// The paired devices, as JSON (read by [`json::paired_devices`]). | |
| 19 | + | fn paired_devices(&self) -> Result<String>; | |
| 20 | + | ||
| 21 | + | /// Opens the RFCOMM link to `service` on the device at `address`, in place of any link open | |
| 22 | + | /// before. | |
| 23 | + | fn connect(&self, address: &str, service: &str) -> Result<()>; | |
| 24 | + | ||
| 25 | + | fn send(&self, bytes: &[u8]) -> Result<()>; | |
| 26 | + | ||
| 27 | + | /// The next bytes from the buds, `None` once `timeout` passes with nothing, or no bytes at all | |
| 28 | + | /// once the link is closed. | |
| 29 | + | fn recv(&self, timeout: Duration) -> Result<Option<Vec<u8>>>; | |
| 30 | + | ||
| 31 | + | fn close(&self); | |
| 32 | + | ||
| 33 | + | fn event(&self, json: &str); | |
| 34 | + | ||
| 35 | + | fn log(&self, line: &str); | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /// An error in the app's side, rather than with the buds: a bug. | |
| 39 | + | pub fn app_error(context: &'static str, detail: impl Display) -> Error { | |
| 40 | + | Error::Os { | |
| 41 | + | context, | |
| 42 | + | source: io::Error::other(detail.to_string()), | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// Finds the buds among the paired devices and opens a session, as `budctl` does through BlueZ. | |
| 47 | + | pub fn connect(phone: Arc<dyn Phone>) -> Connect { | |
| 48 | + | Box::new(move |found| { | |
| 49 | + | let paired = json::paired_devices(&phone.paired_devices()?)?; | |
| 50 | + | let found_list = paired | |
| 51 | + | .into_iter() | |
| 52 | + | .map(|device| Found { | |
| 53 | + | model: devices::identify(&device), | |
| 54 | + | device, | |
| 55 | + | }) | |
| 56 | + | .collect(); | |
| 57 | + | let (device, model) = budctl_core::select(found_list, None)?; | |
| 58 | + | found(device.alias.clone()); | |
| 59 | + | budctl_core::ensure_connected(&device)?; | |
| 60 | + | ||
| 61 | + | // The first of the model's services the buds list, as SDP finds it on Linux. Android | |
| 62 | + | // caches the list; when it has none yet, the first service is tried. | |
| 63 | + | let address = device.address.to_string(); | |
| 64 | + | let listed = |service: &String| device.uuids.is_empty() || device.uuids.contains(service); | |
| 65 | + | let service = model | |
| 66 | + | .rfcomm_uuids() | |
| 67 | + | .iter() | |
| 68 | + | .map(ToString::to_string) | |
| 69 | + | .find(listed) | |
| 70 | + | .ok_or_else(|| Error::NoService(address.clone()))?; | |
| 71 | + | phone.connect(&address, &service)?; | |
| 72 | + | budctl_core::open(model, Box::new(Link(phone.clone()))) | |
| 73 | + | }) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /// The RFCOMM link the app opened, as a [`Transport`]. Dropping it closes the link. | |
| 77 | + | struct Link(Arc<dyn Phone>); | |
| 78 | + | ||
| 79 | + | impl Transport for Link { | |
| 80 | + | fn send(&mut self, bytes: &[u8]) -> Result<()> { | |
| 81 | + | self.0.send(bytes) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | fn recv(&mut self, deadline: Instant) -> Result<Option<Vec<u8>>> { | |
| 85 | + | match self.0.recv(deadline.saturating_duration_since(Instant::now()))? { | |
| 86 | + | Some(bytes) if bytes.is_empty() => Err(Error::NotConnected( | |
| 87 | + | "the buds closed the control link (case lid closed?)".to_owned(), | |
| 88 | + | )), | |
| 89 | + | received => Ok(received), | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | impl Drop for Link { | |
| 95 | + | fn drop(&mut self) { | |
| 96 | + | self.0.close(); | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | /// The worker's reports, passed on to the app as JSON. | |
| 101 | + | pub struct Reporter(pub Arc<dyn Phone>); | |
| 102 | + | ||
| 103 | + | impl Report for Reporter { | |
| 104 | + | fn event(&self, event: Event) { | |
| 105 | + | match json::event(&event) { | |
| 106 | + | Ok(json) => self.0.event(&json), | |
| 107 | + | Err(e) => self.0.log(&format!("could not write an event as JSON: {e}")), | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | fn log(&self, line: &str) { | |
| 112 | + | self.0.log(line); | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | #[cfg(test)] | |
| 117 | + | mod tests { | |
| 118 | + | use super::*; | |
| 119 | + | use budctl_core::Kind; | |
| 120 | + | use std::collections::VecDeque; | |
| 121 | + | use std::sync::Mutex; | |
| 122 | + | ||
| 123 | + | const T310: &str = "realme Buds T310"; | |
| 124 | + | const FIRST: &str = "0000079a-d102-11e1-9b23-00025b00a5a5"; | |
| 125 | + | const SECOND: &str = "00001107-d102-11e1-9b23-00025b00a5a5"; | |
| 126 | + | ||
| 127 | + | /// A phone with one paired device, whose connect answers as told. | |
| 128 | + | #[derive(Default)] | |
| 129 | + | struct Fake { | |
| 130 | + | devices: String, | |
| 131 | + | refuse: Option<String>, | |
| 132 | + | /// What `recv` returns, in order; then nothing. | |
| 133 | + | incoming: Mutex<VecDeque<Option<Vec<u8>>>>, | |
| 134 | + | calls: Mutex<Vec<String>>, | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | impl Fake { | |
| 138 | + | fn with(name: &str, connected: bool, uuids: &[&str]) -> Self { | |
| 139 | + | let device = serde_json::json!([{ | |
| 140 | + | "address": "00:11:22:33:44:55", "name": name, "alias": "My buds", | |
| 141 | + | "connected": connected, "uuids": uuids, | |
| 142 | + | }]); | |
| 143 | + | Fake { | |
| 144 | + | devices: device.to_string(), | |
| 145 | + | ..Fake::default() | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | fn calls(&self) -> Vec<String> { | |
| 150 | + | self.calls.lock().unwrap().clone() | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | impl Phone for Fake { | |
| 155 | + | fn paired_devices(&self) -> Result<String> { | |
| 156 | + | Ok(self.devices.clone()) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | fn connect(&self, address: &str, service: &str) -> Result<()> { | |
| 160 | + | self.calls.lock().unwrap().push(format!("connect {address} {service}")); | |
| 161 | + | match &self.refuse { | |
| 162 | + | Some(json) => Err(json::connect_failure(json)), | |
| 163 | + | None => Ok(()), | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | fn send(&self, _bytes: &[u8]) -> Result<()> { | |
| 168 | + | Ok(()) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | fn recv(&self, _timeout: Duration) -> Result<Option<Vec<u8>>> { | |
| 172 | + | Ok(self.incoming.lock().unwrap().pop_front().flatten()) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | fn close(&self) { | |
| 176 | + | self.calls.lock().unwrap().push("close".to_owned()); | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | fn event(&self, json: &str) { | |
| 180 | + | self.calls.lock().unwrap().push(json.to_owned()); | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | fn log(&self, _line: &str) {} | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | /// Runs the Android connect against `phone`, and the names it reported. | |
| 187 | + | fn open(phone: &Arc<Fake>) -> (Result<()>, Vec<String>) { | |
| 188 | + | let found = Mutex::new(Vec::new()); | |
| 189 | + | let result = connect(phone.clone())(&|name| found.lock().unwrap().push(name)).map(drop); | |
| 190 | + | (result, found.into_inner().unwrap()) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | #[test] | |
| 194 | + | fn no_supported_buds_is_not_found() { | |
| 195 | + | let phone = Arc::new(Fake::with("Some Speaker", true, &[])); | |
| 196 | + | let (result, found) = open(&phone); | |
| 197 | + | assert!(matches!(result, Err(Error::NoDevice(_)))); | |
| 198 | + | assert!(found.is_empty()); | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | #[test] | |
| 202 | + | fn buds_that_are_not_connected_are_named_but_not_dialled() { | |
| 203 | + | let phone = Arc::new(Fake::with(T310, false, &[FIRST])); | |
| 204 | + | let (result, found) = open(&phone); | |
| 205 | + | assert!(matches!(result, Err(Error::NotConnected(message)) if message.contains("My buds"))); | |
| 206 | + | assert_eq!(found, ["My buds"]); | |
| 207 | + | assert!(phone.calls().is_empty()); | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | #[test] | |
| 211 | + | fn a_refused_connect_keeps_its_kind() { | |
| 212 | + | let phone = Arc::new(Fake { | |
| 213 | + | refuse: Some(r#"{"kind": "busy", "message": "refused"}"#.to_owned()), | |
| 214 | + | ..Fake::with(T310, true, &[FIRST]) | |
| 215 | + | }); | |
| 216 | + | let (result, _) = open(&phone); | |
| 217 | + | assert_eq!(result.err().map(|e| e.kind()), Some(Kind::Busy)); | |
| 218 | + | assert_eq!(phone.calls(), [format!("connect 00:11:22:33:44:55 {FIRST}")]); | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | #[test] | |
| 222 | + | fn the_first_service_the_buds_list_is_used() { | |
| 223 | + | let phone = Arc::new(Fake { | |
| 224 | + | refuse: Some(r#"{"kind": "busy", "message": "refused"}"#.to_owned()), | |
| 225 | + | ..Fake::with(T310, true, &[SECOND]) | |
| 226 | + | }); | |
| 227 | + | let _ = open(&phone); | |
| 228 | + | assert_eq!(phone.calls(), [format!("connect 00:11:22:33:44:55 {SECOND}")]); | |
| 229 | + | ||
| 230 | + | let phone = Arc::new(Fake::with(T310, true, &["0000110b-0000-1000-8000-00805f9b34fb"])); | |
| 231 | + | assert!(matches!(open(&phone).0, Err(Error::NoService(_)))); | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | #[test] | |
| 235 | + | fn the_link_reports_silence_and_a_closed_link() { | |
| 236 | + | let phone = Arc::new(Fake::default()); | |
| 237 | + | phone | |
| 238 | + | .incoming | |
| 239 | + | .lock() | |
| 240 | + | .unwrap() | |
| 241 | + | .extend([Some(vec![0xAA, 0x07]), None, Some(Vec::new())]); | |
| 242 | + | let mut link = Link(phone.clone()); | |
| 243 | + | let deadline = Instant::now(); | |
| 244 | + | assert_eq!(link.recv(deadline).unwrap(), Some(vec![0xAA, 0x07])); | |
| 245 | + | assert_eq!(link.recv(deadline).unwrap(), None); | |
| 246 | + | assert!(matches!(link.recv(deadline), Err(Error::NotConnected(_)))); | |
| 247 | + | drop(link); | |
| 248 | + | assert_eq!(phone.calls(), ["close"]); | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | #[test] | |
| 252 | + | fn reports_reach_the_app_as_json() { | |
| 253 | + | let phone = Arc::new(Fake::default()); | |
| 254 | + | Reporter(phone.clone()).event(Event::Lost("gone".to_owned())); | |
| 255 | + | assert_eq!(phone.calls(), [r#"{"event":"lost","message":"gone"}"#]); | |
| 256 | + | } | |
| 257 | + | } | |
| @@ -1,22 +1,50 @@ | |||
| 1 | − | //! `--debug` output on stderr: a hex dump of every byte sent and received, plus notes on what | |
| 2 | − | //! budctl decoded and decided. Off by default. | |
| 1 | + | //! `--debug` output: a hex dump of every byte sent and received, plus notes on what budctl | |
| 2 | + | //! decoded and decided. Off by default. It goes to stderr, unless a front end sends it elsewhere | |
| 3 | + | //! (the Android app, whose stderr goes nowhere). | |
| 3 | 4 | ||
| 5 | + | use std::sync::RwLock; | |
| 4 | 6 | use std::sync::atomic::{AtomicBool, Ordering}; | |
| 5 | 7 | ||
| 6 | 8 | static ENABLED: AtomicBool = AtomicBool::new(false); | |
| 7 | 9 | ||
| 10 | + | /// Where debug lines go instead of stderr, once set. | |
| 11 | + | type Output = Box<dyn Fn(&str) + Send + Sync>; | |
| 12 | + | static OUTPUT: RwLock<Option<Output>> = RwLock::new(None); | |
| 13 | + | ||
| 8 | 14 | pub fn enable() { | |
| 9 | 15 | ENABLED.store(true, Ordering::Relaxed); | |
| 10 | 16 | } | |
| 11 | 17 | ||
| 18 | + | pub fn disable() { | |
| 19 | + | ENABLED.store(false, Ordering::Relaxed); | |
| 20 | + | } | |
| 21 | + | ||
| 12 | 22 | pub fn enabled() -> bool { | |
| 13 | 23 | ENABLED.load(Ordering::Relaxed) | |
| 14 | 24 | } | |
| 15 | 25 | ||
| 26 | + | /// Send debug lines to `output` instead of stderr. A later call replaces an earlier one. | |
| 27 | + | pub fn set_output(output: Output) { | |
| 28 | + | if let Ok(mut slot) = OUTPUT.write() { | |
| 29 | + | *slot = Some(output); | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /// Print one debug line. | |
| 34 | + | pub(crate) fn line(text: &str) { | |
| 35 | + | if let Ok(slot) = OUTPUT.read() | |
| 36 | + | && let Some(output) = slot.as_ref() | |
| 37 | + | { | |
| 38 | + | output(text); | |
| 39 | + | } else { | |
| 40 | + | eprintln!("{text}"); | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 16 | 44 | /// Print a note when debugging is on. The message is only built if it will be printed. | |
| 17 | 45 | pub(crate) fn note(message: impl FnOnce() -> String) { | |
| 18 | 46 | if enabled() { | |
| 19 | − | eprintln!("# {}", message()); | |
| 47 | + | line(&format!("# {}", message())); | |
| 20 | 48 | } | |
| 21 | 49 | } | |
| 22 | 50 | ||
| @@ -21,13 +21,12 @@ pub mod worker; | |||
| 21 | 21 | pub use error::{Error, Kind, Result}; | |
| 22 | 22 | ||
| 23 | 23 | use bt::BdAddr; | |
| 24 | − | use devices::Device; | |
| 25 | − | #[cfg(target_os = "linux")] | |
| 26 | − | use devices::Session; | |
| 24 | + | use devices::{Device, Session}; | |
| 27 | 25 | #[cfg(target_os = "linux")] | |
| 28 | 26 | use transport::rfcomm::Rfcomm; | |
| 29 | 27 | #[cfg(target_os = "linux")] | |
| 30 | − | use transport::{Hexdump, Transport, sdp}; | |
| 28 | + | use transport::sdp; | |
| 29 | + | use transport::{Hexdump, Transport}; | |
| 31 | 30 | ||
| 32 | 31 | /// A paired device, as the system describes it: BlueZ on Linux, the app's Kotlin side on Android. | |
| 33 | 32 | #[derive(Debug, Clone)] | |
| @@ -116,29 +115,41 @@ pub fn select(found: Vec<Found>, address: Option<BdAddr>) -> Result<(PairedDevic | |||
| 116 | 115 | /// init sequence. | |
| 117 | 116 | #[cfg(target_os = "linux")] | |
| 118 | 117 | pub fn connect(device: &PairedDevice, model: &'static dyn Device) -> Result<Box<dyn Session>> { | |
| 119 | − | if !device.connected { | |
| 120 | − | return Err(Error::NotConnected(format!( | |
| 121 | − | "{} ({}) is paired but not connected; connect it in your Bluetooth settings first", | |
| 122 | − | device.alias, device.address | |
| 123 | − | ))); | |
| 124 | − | } | |
| 118 | + | ensure_connected(device)?; | |
| 125 | 119 | for &service in model.rfcomm_uuids() { | |
| 126 | 120 | let Some(channel) = sdp::rfcomm_channel(device.address, service)? else { | |
| 127 | 121 | debug::note(|| format!("SDP: {} has no service {service}", device.address)); | |
| 128 | 122 | continue; | |
| 129 | 123 | }; | |
| 130 | 124 | debug::note(|| format!("SDP: service {service} is on RFCOMM channel {channel}")); | |
| 131 | − | let rfcomm = Box::new(Rfcomm::connect(device.address, channel)?); | |
| 132 | − | let link: Box<dyn Transport> = if debug::enabled() { | |
| 133 | − | Box::new(Hexdump::new(rfcomm)) | |
| 134 | − | } else { | |
| 135 | − | rfcomm | |
| 136 | − | }; | |
| 137 | − | return model.open(link); | |
| 125 | + | return open(model, Box::new(Rfcomm::connect(device.address, channel)?)); | |
| 138 | 126 | } | |
| 139 | 127 | Err(Error::NoService(device.address.to_string())) | |
| 140 | 128 | } | |
| 141 | 129 | ||
| 130 | + | /// The control channel only opens while the buds are connected (the audio link is up): say so | |
| 131 | + | /// plainly instead of failing at the connect. | |
| 132 | + | pub fn ensure_connected(device: &PairedDevice) -> Result<()> { | |
| 133 | + | if device.connected { | |
| 134 | + | return Ok(()); | |
| 135 | + | } | |
| 136 | + | Err(Error::NotConnected(format!( | |
| 137 | + | "{} ({}) is paired but not connected; connect it in your Bluetooth settings first", | |
| 138 | + | device.alias, device.address | |
| 139 | + | ))) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | /// Run the model's init sequence on a fresh link to the buds and return the session. With debug | |
| 143 | + | /// on, every chunk on the link is dumped. | |
| 144 | + | pub fn open(model: &'static dyn Device, link: Box<dyn Transport>) -> Result<Box<dyn Session>> { | |
| 145 | + | let link: Box<dyn Transport> = if debug::enabled() { | |
| 146 | + | Box::new(Hexdump::new(link)) | |
| 147 | + | } else { | |
| 148 | + | link | |
| 149 | + | }; | |
| 150 | + | model.open(link) | |
| 151 | + | } | |
| 152 | + | ||
| 142 | 153 | #[cfg(test)] | |
| 143 | 154 | mod tests { | |
| 144 | 155 | use super::*; | |
| @@ -45,7 +45,7 @@ impl Hexdump { | |||
| 45 | 45 | ||
| 46 | 46 | fn print(&self, direction: &str, bytes: &[u8]) { | |
| 47 | 47 | let elapsed = self.start.elapsed().as_secs_f64(); | |
| 48 | − | eprintln!("{elapsed:>7.3} {direction} {}", debug::hex(bytes)); | |
| 48 | + | debug::line(&format!("{elapsed:>7.3} {direction} {}", debug::hex(bytes))); | |
| 49 | 49 | } | |
| 50 | 50 | } | |
| 51 | 51 | ||
| @@ -28,7 +28,7 @@ pub enum Command { | |||
| 28 | 28 | Read, | |
| 29 | 29 | Noise(NoiseMode), | |
| 30 | 30 | /// A switch's key, and whether to turn it on. | |
| 31 | − | Switch(&'static str, bool), | |
| 31 | + | Switch(String, bool), | |
| 32 | 32 | /// Select a built-in EQ preset, by name. | |
| 33 | 33 | Eq(String), | |
| 34 | 34 | /// Save a custom EQ preset (`custom-<id>`) with these bands changed, which also selects it. | |
| @@ -195,7 +195,7 @@ impl Worker { | |||
| 195 | 195 | .map(Event::Noise), | |
| 196 | 196 | Command::Switch(key, on) => self | |
| 197 | 197 | .session() | |
| 198 | − | .and_then(|session| session.set_switch(key, on)) | |
| 198 | + | .and_then(|session| session.set_switch(&key, on)) | |
| 199 | 199 | .map(Event::Switch), | |
| 200 | 200 | Command::Eq(preset) => self | |
| 201 | 201 | .session() | |
| @@ -568,11 +568,14 @@ impl App { | |||
| 568 | 568 | Message::Noise(mode) => self.write(Item::NoiseMode, Command::Noise(mode)), | |
| 569 | 569 | // Turning dual connection off may drop one of two connected devices: ask first. | |
| 570 | 570 | Message::Switch(earbuds::DUAL, false) => self.confirm_dual = true, | |
| 571 | − | Message::Switch(key, on) => self.write(Item::Switch(key), Command::Switch(key, on)), | |
| 571 | + | Message::Switch(key, on) => self.write(Item::Switch(key), Command::Switch(key.to_owned(), on)), | |
| 572 | 572 | Message::ConfirmDual(yes) => { | |
| 573 | 573 | self.confirm_dual = false; | |
| 574 | 574 | if yes { | |
| 575 | − | self.write(Item::Switch(earbuds::DUAL), Command::Switch(earbuds::DUAL, false)); | |
| 575 | + | self.write( | |
| 576 | + | Item::Switch(earbuds::DUAL), | |
| 577 | + | Command::Switch(earbuds::DUAL.to_owned(), false), | |
| 578 | + | ); | |
| 576 | 579 | } | |
| 577 | 580 | } | |
| 578 | 581 | Message::Preset(id) => self.select(id), | |
| @@ -2126,7 +2129,7 @@ mod tests { | |||
| 2126 | 2129 | let _ = app.update(Message::Worker(Event::Read(Box::new(reading())))); | |
| 2127 | 2130 | ||
| 2128 | 2131 | let _ = app.update(Message::Switch("game", true)); | |
| 2129 | − | assert!(matches!(commands.try_recv(), Ok(Command::Switch("game", true)))); | |
| 2132 | + | assert!(matches!(commands.try_recv(), Ok(Command::Switch(key, true)) if key == "game")); | |
| 2130 | 2133 | assert!(!switch(&app, "game"), "the switch changes when the buds say so"); | |
| 2131 | 2134 | assert_eq!(app.pending(), [Item::Switch("game")]); | |
| 2132 | 2135 | // While it is on its way, the same switch takes no other write. | |
| @@ -2510,7 +2513,7 @@ mod tests { | |||
| 2510 | 2513 | let _ = app.update(Message::Switch("dual", false)); | |
| 2511 | 2514 | assert!(commands.try_recv().is_err()); | |
| 2512 | 2515 | let _ = app.update(Message::ConfirmDual(true)); | |
| 2513 | − | assert!(matches!(commands.try_recv(), Ok(Command::Switch("dual", false)))); | |
| 2516 | + | assert!(matches!(commands.try_recv(), Ok(Command::Switch(key, false)) if key == "dual")); | |
| 2514 | 2517 | } | |
| 2515 | 2518 | ||
| 2516 | 2519 | #[test] | |
| @@ -47,21 +47,26 @@ Rust exports, called from Kotlin (`Core.kt`): | |||
| 47 | 47 | ||
| 48 | 48 | | Function | Does | | |
| 49 | 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` | | |
| 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 | 52 | | `stop()` | ends the worker, which closes any session | | |
| 53 | 53 | ||
| 54 | − | Kotlin's side, called on the worker's thread: | |
| 54 | + | Kotlin's side (`Platform`), called on the worker's thread. It catches its own exceptions and | |
| 55 | + | answers with values: | |
| 55 | 56 | ||
| 56 | 57 | | Method | Does | | |
| 57 | 58 | |---|---| | |
| 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 | | |
| 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 | | |
| 60 | 64 | | `event(json)` | one worker event, for the UI | | |
| 61 | 65 | | `log(line)` | a session or debug line, for logcat and the in-app log | | |
| 62 | 66 | ||
| 63 | − | The link that `connect` returns has `send(bytes)`, `recv(timeoutMs)` (the bytes, or null once the | |
| 64 | − | time runs out) and `close()`. | |
| 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. | |
| 65 | 70 | ||
| 66 | 71 | - JSON keeps the boundary small, and it carries the same serde types the CLI's `--json` writes. | |
| 67 | 72 | - Rust picks the device with `budctl_core::select`, as the CLI does, so the messages ("not | |
| @@ -80,8 +85,9 @@ Written from Android's API reference. No other app's code is read for it (rule 2 | |||
| 80 | 85 | - **Paired devices:** `BluetoothAdapter.getBondedDevices()` gives the name, alias, address and | |
| 81 | 86 | cached service UUIDs. Whether the buds are connected comes from the A2DP and headset profile | |
| 82 | 87 | 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 | |
| 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 | |
| 85 | 91 | timeout, so a watchdog closes the socket after 8 s, when `rfcomm.rs` gives up too. | |
| 86 | 92 | - **Reads with a timeout:** the socket's input stream has no read timeout. A reader thread puts | |
| 87 | 93 | what it reads in a queue, `recv(timeoutMs)` waits on the queue, and `close()` ends the thread. | |
| @@ -173,10 +179,8 @@ Run on the phone with debug on, and paste the in-app log. | |||
| 173 | 179 | - arm64 for the phone and the release APK; debug builds add x86_64 for the emulator. | |
| 174 | 180 | - The application id is `io.github.krolyxon.budctl`. | |
| 175 | 181 | - 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. | |
| 182 | + | - Rule 5's second exception: the JNI exports' `#[unsafe(no_mangle)]`, in | |
| 183 | + | `crates/budctl-android/src/lib.rs`, with nothing else unsafe in that crate. | |
| 180 | 184 | ||
| 181 | 185 | ## Not planned | |
| 182 | 186 | ||