Skip to main content

android/adb/
devices.rs

1//! Target listing: the adb server's answer to `host:devices-l`, the text that
2//! `adb devices -l` prints, with every connection state kept, and the targets
3//! built from it.
4
5use std::collections::BTreeMap;
6
7/// Connection state of a listed target, as the adb server names it.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum State {
10    /// Online and authorized.
11    Device,
12    Offline,
13    /// USB debugging has not been accepted on the device.
14    Unauthorized,
15    Authorizing,
16    Connecting,
17    /// `no permissions (...)`, with the server's explanation kept verbatim.
18    NoPermissions(String),
19    /// Any other state (bootloader, recovery, rescue, sideload, host,
20    /// detached), verbatim.
21    Other(String),
22}
23
24impl State {
25    /// What the user can do about this state; `None` for [`State::Device`].
26    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/// One line of the listing.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct DeviceEntry {
64    pub serial: String,
65    pub state: State,
66    /// The long form's trailing `key:value` fields: usb, product, model, device
67    /// and transport_id.
68    pub props: BTreeMap<String, String>,
69}
70
71impl DeviceEntry {
72    /// The server's transport id, present in the long form only.
73    pub fn transport_id(&self) -> Option<u32> {
74        self.props.get("transport_id")?.parse().ok()
75    }
76
77    /// The model the server reports, with spaces replaced by underscores.
78    pub fn model(&self) -> Option<&str> {
79        self.props.get("model").map(String::as_str)
80    }
81}
82
83/// Parses a `host:devices` or `host:devices-l` answer. Header, blank and
84/// `* daemon ...` lines are skipped, as is any line without a state, so one odd
85/// line never hides the other targets.
86pub fn parse_devices(listing: &str) -> Vec<DeviceEntry> {
87    listing.lines().filter_map(parse_line).collect()
88}
89
90/// Keys of the long form's trailing fields. The server appends them after the
91/// state, ending with transport_id, so they are taken from the end of the line.
92const PROP_KEYS: [&str; 5] = ["usb", "product", "model", "device", "transport_id"];
93
94/// What the server prints for a target whose serial is empty.
95const 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    // A state may contain spaces: "no permissions (...); see [...]".
119    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
135/// The shell command [`crate::adb::AdbServer::targets`] runs on each online
136/// target, one property per output line in this order.
137pub const PROP_QUERY: &str = "getprop ro.build.version.sdk; getprop ro.boot.qemu; \
138                              getprop ro.kernel.qemu; getprop ro.product.model";
139
140/// A listed target with what the driver reports about it.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct Target {
143    pub serial: String,
144    pub state: State,
145    /// `ro.product.model` for an online target, else the listing's model.
146    pub model: Option<String>,
147    /// `ro.build.version.sdk`; read from online targets only.
148    pub api_level: Option<u32>,
149    /// From `ro.boot.qemu` or `ro.kernel.qemu` when the target is online, else
150    /// from an `emulator-` serial.
151    pub emulator: bool,
152    pub transport_id: Option<u32>,
153}
154
155impl Target {
156    /// A target from its listing entry and, when it could be read, the output
157    /// of [`PROP_QUERY`] on it.
158    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    /// What the user can do to make the target usable; `None` when it is.
179    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}