1use std::collections::BTreeMap;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum State {
10 Device,
12 Offline,
13 Unauthorized,
15 Authorizing,
16 Connecting,
17 NoPermissions(String),
19 Other(String),
22}
23
24impl State {
25 pub fn advice(&self) -> Option<String> {
27 let text = match self {
28 State::Device => return None,
29 State::Unauthorized => {
30 "USB debugging is not authorized for this computer: unlock the device and \
31 accept the \"Allow USB debugging?\" prompt on the device, ticking \"Always \
32 allow from this computer\". If no prompt appears, choose \"Revoke USB \
33 debugging authorizations\" in Developer options and reconnect the cable."
34 .to_owned()
35 }
36 State::Offline => "adb has lost contact with the device: reconnect the cable, or \
37 restart the emulator; `adb reconnect offline` retries without \
38 unplugging."
39 .to_owned(),
40 State::Authorizing => {
41 "the device is still authorizing this computer; list targets again in a moment."
42 .to_owned()
43 }
44 State::Connecting => {
45 "adb is still connecting to the device; list targets again in a moment.".to_owned()
46 }
47 State::NoPermissions(detail) => format!(
48 "the adb server may not open this USB device ({detail}): install the udev \
49 rules for Android devices, add your user to the plugdev group, then reconnect \
50 the device and restart the adb server."
51 ),
52 State::Other(mode) => format!(
53 "the device is in {mode} mode, not running Android: reboot it into Android to \
54 automate it."
55 ),
56 };
57 Some(text)
58 }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct DeviceEntry {
64 pub serial: String,
65 pub state: State,
66 pub props: BTreeMap<String, String>,
69}
70
71impl DeviceEntry {
72 pub fn transport_id(&self) -> Option<u32> {
74 self.props.get("transport_id")?.parse().ok()
75 }
76
77 pub fn model(&self) -> Option<&str> {
79 self.props.get("model").map(String::as_str)
80 }
81}
82
83pub fn parse_devices(listing: &str) -> Vec<DeviceEntry> {
87 listing.lines().filter_map(parse_line).collect()
88}
89
90const PROP_KEYS: [&str; 5] = ["usb", "product", "model", "device", "transport_id"];
93
94const NO_SERIAL: &str = "(no serial number)";
96
97fn parse_line(line: &str) -> Option<DeviceEntry> {
98 let line = line.trim();
99 if line.is_empty() || line.starts_with("List of devices") || line.starts_with('*') {
100 return None;
101 }
102 let (serial, rest) = match line.strip_prefix(NO_SERIAL) {
103 Some(rest) => (NO_SERIAL, rest),
104 None => line.split_once(char::is_whitespace)?,
105 };
106 let mut tokens: Vec<&str> = rest.split_whitespace().collect();
107 let mut props = BTreeMap::new();
108 while let Some((key, value)) = tokens.last().and_then(|t| t.split_once(':')) {
109 if !PROP_KEYS.contains(&key) {
110 break;
111 }
112 props.insert(key.to_owned(), value.to_owned());
113 tokens.pop();
114 }
115 if tokens.is_empty() {
116 return None;
117 }
118 let state = match tokens.join(" ").as_str() {
120 "device" => State::Device,
121 "offline" => State::Offline,
122 "unauthorized" => State::Unauthorized,
123 "authorizing" => State::Authorizing,
124 "connecting" => State::Connecting,
125 text if text.starts_with("no permissions") => State::NoPermissions(text.to_owned()),
126 text => State::Other(text.to_owned()),
127 };
128 Some(DeviceEntry {
129 serial: serial.to_owned(),
130 state,
131 props,
132 })
133}
134
135pub const PROP_QUERY: &str = "getprop ro.build.version.sdk; getprop ro.boot.qemu; \
138 getprop ro.kernel.qemu; getprop ro.product.model";
139
140#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct Target {
143 pub serial: String,
144 pub state: State,
145 pub model: Option<String>,
147 pub api_level: Option<u32>,
149 pub emulator: bool,
152 pub transport_id: Option<u32>,
153}
154
155impl Target {
156 pub fn new(entry: DeviceEntry, props: Option<&str>) -> Self {
159 let lines: Vec<&str> = props
160 .map(|p| p.lines().map(str::trim).collect())
161 .unwrap_or_default();
162 let prop = |i: usize| lines.get(i).copied().filter(|v| !v.is_empty());
163 let api_level = prop(0).and_then(|v| v.parse().ok());
164 let qemu = prop(1) == Some("1") || prop(2) == Some("1");
165 let model = prop(3)
166 .map(str::to_owned)
167 .or_else(|| entry.model().map(str::to_owned));
168 Target {
169 emulator: qemu || entry.serial.starts_with("emulator-"),
170 transport_id: entry.transport_id(),
171 serial: entry.serial,
172 state: entry.state,
173 model,
174 api_level,
175 }
176 }
177
178 pub fn advice(&self) -> Option<String> {
180 self.state.advice()
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 const EMULATOR: &str = "emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1";
189
190 fn one(listing: &str) -> DeviceEntry {
191 let mut all = parse_devices(listing);
192 assert_eq!(
193 all.len(),
194 1,
195 "expected one entry in {listing:?}, got {all:?}"
196 );
197 all.remove(0)
198 }
199
200 #[test]
201 fn parses_the_booted_emulator_line() {
202 let e = one(EMULATOR);
203 assert_eq!(e.serial, "emulator-5554");
204 assert_eq!(e.state, State::Device);
205 assert_eq!(e.props["product"], "sdk_gphone64_arm64");
206 assert_eq!(e.model(), Some("sdk_gphone64_arm64"));
207 assert_eq!(e.props["device"], "emu64a");
208 assert_eq!(e.transport_id(), Some(1));
209 }
210
211 #[test]
212 fn skips_header_blank_and_daemon_status_lines() {
213 let listing = format!(
214 "* daemon not running; starting now at tcp:5037\n* daemon started successfully\nList of devices attached\n{EMULATOR}\n\n"
215 );
216 assert_eq!(one(&listing).serial, "emulator-5554");
217 }
218
219 #[test]
220 fn keeps_unauthorized_offline_and_authorizing_targets() {
221 let listing = "List of devices attached\n\
222 R58M123ABC unauthorized usb:1-1 transport_id:2\n\
223 emulator-5556 offline transport_id:3\n\
224 emulator-5558 authorizing transport_id:7\n\
225 emulator-5560 connecting transport_id:8\n";
226 let all = parse_devices(listing);
227 let states: Vec<_> = all
228 .iter()
229 .map(|e| (e.serial.as_str(), e.state.clone()))
230 .collect();
231 assert_eq!(
232 states,
233 [
234 ("R58M123ABC", State::Unauthorized),
235 ("emulator-5556", State::Offline),
236 ("emulator-5558", State::Authorizing),
237 ("emulator-5560", State::Connecting),
238 ]
239 );
240 assert_eq!(all[0].props["usb"], "1-1");
241 assert_eq!(all[0].model(), None);
242 let ids: Vec<_> = all.iter().map(DeviceEntry::transport_id).collect();
243 assert_eq!(ids, [Some(2), Some(3), Some(7), Some(8)]);
244 }
245
246 #[test]
247 fn keeps_the_no_permissions_explanation() {
248 let e = one(
249 "0123456789ABCDEF no permissions (missing udev rules? user is in the plugdev group); see [http://developer.android.com/tools/device.html] usb:1-2 transport_id:4",
250 );
251 assert_eq!(e.serial, "0123456789ABCDEF");
252 assert_eq!(
253 e.state,
254 State::NoPermissions(
255 "no permissions (missing udev rules? user is in the plugdev group); see [http://developer.android.com/tools/device.html]".into()
256 )
257 );
258 assert_eq!(e.props["usb"], "1-2");
259 assert_eq!(e.transport_id(), Some(4));
260 }
261
262 #[test]
263 fn parses_network_and_mdns_serials() {
264 let listing = "192.168.1.20:5555 device product:husky model:Pixel_8_Pro device:husky transport_id:5\n\
265 adb-35091FDH2000J2-UfqGmB._adb-tls-connect._tcp device product:shiba model:Pixel_8 device:shiba transport_id:6\n";
266 let all = parse_devices(listing);
267 assert_eq!(all.len(), 2);
268 assert_eq!(all[0].serial, "192.168.1.20:5555");
269 assert_eq!(all[0].model(), Some("Pixel_8_Pro"));
270 assert_eq!(
271 all[1].serial,
272 "adb-35091FDH2000J2-UfqGmB._adb-tls-connect._tcp"
273 );
274 assert_eq!(all[1].state, State::Device);
275 }
276
277 #[test]
278 fn parses_the_short_tab_separated_form() {
279 let all = parse_devices(
280 "List of devices attached\nemulator-5554\tdevice\nR58M123ABC\tunauthorized\n\n",
281 );
282 assert_eq!(all.len(), 2);
283 assert_eq!(all[0].serial, "emulator-5554");
284 assert_eq!(all[0].state, State::Device);
285 assert!(all[0].props.is_empty());
286 assert_eq!(all[0].transport_id(), None);
287 assert_eq!(all[1].state, State::Unauthorized);
288 }
289
290 #[test]
291 fn keeps_the_no_serial_placeholder() {
292 let e = one("(no serial number) unauthorized usb:1-3 transport_id:8");
293 assert_eq!(e.serial, "(no serial number)");
294 assert_eq!(e.state, State::Unauthorized);
295 assert_eq!(e.transport_id(), Some(8));
296 }
297
298 #[test]
299 fn other_states_are_kept_verbatim() {
300 assert_eq!(
301 one("ZY22ABCDEF recovery usb:1-4 transport_id:9").state,
302 State::Other("recovery".into())
303 );
304 }
305
306 #[test]
307 fn no_targets_is_an_empty_list() {
308 assert!(parse_devices("List of devices attached\n\n").is_empty());
309 assert!(parse_devices("").is_empty());
310 }
311
312 #[test]
313 fn a_line_without_a_state_is_skipped_and_the_rest_survive() {
314 let listing = format!("garbage\n{EMULATOR}\n");
315 assert_eq!(one(&listing).serial, "emulator-5554");
316 }
317
318 #[test]
319 fn crlf_line_endings_are_accepted() {
320 let e = one(&format!("List of devices attached\r\n{EMULATOR}\r\n\r\n"));
321 assert_eq!(e.transport_id(), Some(1));
322 assert_eq!(e.props["device"], "emu64a");
323 }
324
325 #[test]
326 fn unauthorized_advice_says_to_accept_the_prompt_on_the_device() {
327 let advice = State::Unauthorized.advice().unwrap();
328 assert!(advice.contains("Allow USB debugging"), "{advice}");
329 assert!(advice.contains("accept"), "{advice}");
330 assert!(advice.contains("on the device"), "{advice}");
331 }
332
333 #[test]
334 fn every_unusable_state_has_advice_and_an_online_device_has_none() {
335 assert_eq!(State::Device.advice(), None);
336 for state in [
337 State::Offline,
338 State::Authorizing,
339 State::Connecting,
340 State::NoPermissions("no permissions (x)".into()),
341 State::Other("bootloader".into()),
342 ] {
343 assert!(state.advice().is_some(), "{state:?} has no advice");
344 }
345 assert!(State::Offline.advice().unwrap().contains("reconnect"));
346 assert!(
347 State::NoPermissions("no permissions (missing udev rules?)".into())
348 .advice()
349 .unwrap()
350 .contains("missing udev rules?")
351 );
352 assert!(
353 State::Other("recovery".into())
354 .advice()
355 .unwrap()
356 .contains("recovery")
357 );
358 }
359
360 #[test]
361 fn an_online_target_takes_its_level_model_and_emulator_flag_from_getprop() {
362 let t = Target::new(one(EMULATOR), Some("36\n1\n\nsdk_gphone64_arm64\n"));
363 assert_eq!(t.api_level, Some(36));
364 assert!(t.emulator);
365 assert_eq!(t.model.as_deref(), Some("sdk_gphone64_arm64"));
366 assert_eq!(t.transport_id, Some(1));
367 assert_eq!(t.advice(), None);
368
369 let phone =
370 one("R58M123ABC device product:husky model:Pixel_8_Pro device:husky transport_id:5");
371 let t = Target::new(phone, Some("35\n\n\nPixel 8 Pro\n"));
372 assert_eq!(t.api_level, Some(35));
373 assert!(!t.emulator);
374 assert_eq!(t.model.as_deref(), Some("Pixel 8 Pro"));
375 }
376
377 #[test]
378 fn older_emulators_are_recognised_by_ro_kernel_qemu() {
379 let t = Target::new(
380 one("emulator-5554 device transport_id:1"),
381 Some("29\n\n1\nAndroid SDK built for x86\n"),
382 );
383 assert!(t.emulator);
384 assert_eq!(t.api_level, Some(29));
385 }
386
387 #[test]
388 fn a_target_without_properties_keeps_the_listing_facts() {
389 let t = Target::new(one("emulator-5556 offline transport_id:3"), None);
390 assert_eq!(t.state, State::Offline);
391 assert_eq!(t.api_level, None);
392 assert!(t.emulator, "an emulator- serial marks an emulator");
393 assert_eq!(t.transport_id, Some(3));
394 let t = Target::new(one(EMULATOR), None);
395 assert_eq!(t.model.as_deref(), Some("sdk_gphone64_arm64"));
396 let t = Target::new(one(EMULATOR), Some("garbage"));
397 assert_eq!(t.api_level, None);
398 }
399}