Krolyxon
Commit e7b881a

Branches

Refs →

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).
Author
krolyxon
Date
2026-11-01 22:14 +0530
Parent
6d9ef16
Commit
e7b881a9e08c7d460cdc8a53bb5bfb7012b62aa5

+1004 −43

14 files changed

  1. M CLAUDE.md +3 −1
  2. M Cargo.lock +10 −0
  3. M Cargo.toml +3 −2
  4. A crates/budctl-android/Cargo.toml +20 −0
  5. A crates/budctl-android/src/json.rs +360 −0
  6. A crates/budctl-android/src/kotlin.rs +151 −0
  7. A crates/budctl-android/src/lib.rs +114 −0
  8. A crates/budctl-android/src/phone.rs +257 −0
  9. M crates/budctl-core/src/debug.rs +31 −3
  10. M crates/budctl-core/src/lib.rs +28 −17
  11. M crates/budctl-core/src/transport/mod.rs +1 −1
  12. M crates/budctl-core/src/worker.rs +2 −2
  13. M crates/budctl-gui/src/main.rs +7 −4
  14. M docs/android.md +17 −13
M CLAUDE.md View
@@ -28,7 +28,7 @@ The first supported device is the **realme Buds T310**. More devices are added a
2828 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`).
2929 - 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.
3030 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).
3232
3333 ## Stack
3434
@@ -36,6 +36,7 @@ The first supported device is the **realme Buds T310**. More devices are added a
3636 - `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")`).
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+ - `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`).
3940 - On Linux, Bluetooth goes through BlueZ:
4041 - Raw `AF_BLUETOOTH` sockets via `libc`: RFCOMM for the control link, L2CAP to read the buds' SDP record.
4142 - `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
4647 - core: `serde`, `serde_json` (config file); on Linux only, `libc` and `zbus`
4748 - cli: `clap`, `serde_json`
4849 - gui: `iced`, `serde`, `serde_json`; `iced_test` for tests
50+ - android: `jni`, `serde`, `serde_json`
4951
5052 ## Device plugin contract
5153
M Cargo.lock View
@@ -334,6 +334,16 @@ dependencies = [
334334 "serde_json",
335335 ]
336336
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+
337347 [[package]]
338348 name = "budctl-core"
339349 version = "0.1.3"
M Cargo.toml View
@@ -1,6 +1,6 @@
11 [workspace]
22 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"]
44
55 [workspace.package]
66 version = "0.1.3"
@@ -10,7 +10,8 @@ license = "GPL-3.0-or-later"
1010 publish = false
1111
1212 [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.
1415 unsafe_code = "deny"
1516
1617 [workspace.lints.clippy]
A crates/budctl-android/Cargo.toml View
@@ -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
A crates/budctl-android/src/json.rs View
@@ -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+}
A crates/budctl-android/src/kotlin.rs View
@@ -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+}
A crates/budctl-android/src/lib.rs View
@@ -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+}
A crates/budctl-android/src/phone.rs View
@@ -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+}
M crates/budctl-core/src/debug.rs View
@@ -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).
34
5+use std::sync::RwLock;
46 use std::sync::atomic::{AtomicBool, Ordering};
57
68 static ENABLED: AtomicBool = AtomicBool::new(false);
79
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+
814 pub fn enable() {
915 ENABLED.store(true, Ordering::Relaxed);
1016 }
1117
18+pub fn disable() {
19+ ENABLED.store(false, Ordering::Relaxed);
20+}
21+
1222 pub fn enabled() -> bool {
1323 ENABLED.load(Ordering::Relaxed)
1424 }
1525
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+
1644 /// Print a note when debugging is on. The message is only built if it will be printed.
1745 pub(crate) fn note(message: impl FnOnce() -> String) {
1846 if enabled() {
19− eprintln!("# {}", message());
47+ line(&format!("# {}", message()));
2048 }
2149 }
2250
M crates/budctl-core/src/lib.rs View
@@ -21,13 +21,12 @@ pub mod worker;
2121 pub use error::{Error, Kind, Result};
2222
2323 use bt::BdAddr;
24−use devices::Device;
25−#[cfg(target_os = "linux")]
26−use devices::Session;
24+use devices::{Device, Session};
2725 #[cfg(target_os = "linux")]
2826 use transport::rfcomm::Rfcomm;
2927 #[cfg(target_os = "linux")]
30−use transport::{Hexdump, Transport, sdp};
28+use transport::sdp;
29+use transport::{Hexdump, Transport};
3130
3231 /// A paired device, as the system describes it: BlueZ on Linux, the app's Kotlin side on Android.
3332 #[derive(Debug, Clone)]
@@ -116,29 +115,41 @@ pub fn select(found: Vec<Found>, address: Option<BdAddr>) -> Result<(PairedDevic
116115 /// init sequence.
117116 #[cfg(target_os = "linux")]
118117 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)?;
125119 for &service in model.rfcomm_uuids() {
126120 let Some(channel) = sdp::rfcomm_channel(device.address, service)? else {
127121 debug::note(|| format!("SDP: {} has no service {service}", device.address));
128122 continue;
129123 };
130124 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)?));
138126 }
139127 Err(Error::NoService(device.address.to_string()))
140128 }
141129
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+
142153 #[cfg(test)]
143154 mod tests {
144155 use super::*;
M crates/budctl-core/src/transport/mod.rs View
@@ -45,7 +45,7 @@ impl Hexdump {
4545
4646 fn print(&self, direction: &str, bytes: &[u8]) {
4747 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)));
4949 }
5050 }
5151
M crates/budctl-core/src/worker.rs View
@@ -28,7 +28,7 @@ pub enum Command {
2828 Read,
2929 Noise(NoiseMode),
3030 /// A switch's key, and whether to turn it on.
31− Switch(&'static str, bool),
31+ Switch(String, bool),
3232 /// Select a built-in EQ preset, by name.
3333 Eq(String),
3434 /// Save a custom EQ preset (`custom-<id>`) with these bands changed, which also selects it.
@@ -195,7 +195,7 @@ impl Worker {
195195 .map(Event::Noise),
196196 Command::Switch(key, on) => self
197197 .session()
198− .and_then(|session| session.set_switch(key, on))
198+ .and_then(|session| session.set_switch(&key, on))
199199 .map(Event::Switch),
200200 Command::Eq(preset) => self
201201 .session()
M crates/budctl-gui/src/main.rs View
@@ -568,11 +568,14 @@ impl App {
568568 Message::Noise(mode) => self.write(Item::NoiseMode, Command::Noise(mode)),
569569 // Turning dual connection off may drop one of two connected devices: ask first.
570570 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)),
572572 Message::ConfirmDual(yes) => {
573573 self.confirm_dual = false;
574574 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+ );
576579 }
577580 }
578581 Message::Preset(id) => self.select(id),
@@ -2126,7 +2129,7 @@ mod tests {
21262129 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
21272130
21282131 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"));
21302133 assert!(!switch(&app, "game"), "the switch changes when the buds say so");
21312134 assert_eq!(app.pending(), [Item::Switch("game")]);
21322135 // While it is on its way, the same switch takes no other write.
@@ -2510,7 +2513,7 @@ mod tests {
25102513 let _ = app.update(Message::Switch("dual", false));
25112514 assert!(commands.try_recv().is_err());
25122515 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"));
25142517 }
25152518
25162519 #[test]
M docs/android.md View
@@ -47,21 +47,26 @@ Rust exports, called from Kotlin (`Core.kt`):
4747
4848 | Function | Does |
4949 |---|---|
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 |
5252 | `stop()` | ends the worker, which closes any session |
5353
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:
5556
5657 | Method | Does |
5758 |---|---|
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 |
6064 | `event(json)` | one worker event, for the UI |
6165 | `log(line)` | a session or debug line, for logcat and the in-app log |
6266
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.
6570
6671 - JSON keeps the boundary small, and it carries the same serde types the CLI's `--json` writes.
6772 - 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
8085 - **Paired devices:** `BluetoothAdapter.getBondedDevices()` gives the name, alias, address and
8186 cached service UUIDs. Whether the buds are connected comes from the A2DP and headset profile
8287 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
8591 timeout, so a watchdog closes the socket after 8 s, when `rfcomm.rs` gives up too.
8692 - **Reads with a timeout:** the socket's input stream has no read timeout. A reader thread puts
8793 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.
173179 - arm64 for the phone and the release APK; debug builds add x86_64 for the emulator.
174180 - The application id is `io.github.krolyxon.budctl`.
175181 - 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.
180184
181185 ## Not planned
182186