| 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 | } |