Skip to main content

android/emulator/
discovery.rs

1//! Emulator discovery files.
2//!
3//! A running emulator advertises itself with one `pid_<pid>.ini` file in its
4//! discovery directory. The file holds `key=value` lines: the console port
5//! (`port.serial`, which names the adb serial `emulator-<port>`), the gRPC
6//! controller port (`grpc.port`, only with `-grpc`) and the bearer token
7//! (`grpc.token`, only with `-grpc-use-token`). Tokens are base64 and may end
8//! in `=`, so a line splits at its first `=` only.
9
10use std::collections::BTreeMap;
11use std::fmt;
12use std::path::{Path, PathBuf};
13use std::time::SystemTime;
14
15/// Parsed contents of one discovery file.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Discovery {
18    /// Emulator process id, from the `pid_<pid>.ini` file name.
19    pub pid: Option<u32>,
20    /// Console port (`port.serial`); the adb serial is `emulator-<port>`.
21    pub console_port: Option<u16>,
22    /// adb port of the emulated device (`port.adb`).
23    pub adb_port: Option<u16>,
24    /// AVD name (`avd.name`).
25    pub avd_name: Option<String>,
26    /// Emulator version (`emulator.version`).
27    pub emulator_version: Option<String>,
28    /// gRPC controller port (`grpc.port`); absent without `-grpc`.
29    pub grpc_port: Option<u16>,
30    /// Bearer token (`grpc.token`); absent without `-grpc-use-token`.
31    pub grpc_token: Option<String>,
32    /// Every key and value as written, including keys not modelled above.
33    pub entries: BTreeMap<String, String>,
34}
35
36/// A discovery file that could not be parsed.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum DiscoveryError {
39    /// A port field held something other than a port number.
40    BadPort { key: String, value: String },
41}
42
43impl fmt::Display for DiscoveryError {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        match self {
46            DiscoveryError::BadPort { key, value } => {
47                write!(f, "{key} is not a port number: {value:?}")
48            }
49        }
50    }
51}
52
53impl std::error::Error for DiscoveryError {}
54
55fn port(entries: &BTreeMap<String, String>, key: &str) -> Result<Option<u16>, DiscoveryError> {
56    entries
57        .get(key)
58        .map(|value| {
59            value.parse().map_err(|_| DiscoveryError::BadPort {
60                key: key.to_owned(),
61                value: value.clone(),
62            })
63        })
64        .transpose()
65}
66
67impl Discovery {
68    /// Parses the text of a discovery file; `pid` comes from the file name.
69    pub fn parse(contents: &str, pid: Option<u32>) -> Result<Self, DiscoveryError> {
70        let mut entries = BTreeMap::new();
71        for line in contents.lines() {
72            let line = line.trim();
73            if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
74                continue;
75            }
76            // Split at the first `=` only: base64 tokens end in `=` padding.
77            let Some((key, value)) = line.split_once('=') else {
78                continue;
79            };
80            entries.insert(key.trim().to_owned(), value.trim().to_owned());
81        }
82        let text = |key: &str| entries.get(key).filter(|v| !v.is_empty()).cloned();
83        Ok(Discovery {
84            pid,
85            console_port: port(&entries, "port.serial")?,
86            adb_port: port(&entries, "port.adb")?,
87            avd_name: text("avd.name"),
88            emulator_version: text("emulator.version"),
89            grpc_port: port(&entries, "grpc.port")?,
90            grpc_token: text("grpc.token"),
91            entries,
92        })
93    }
94
95    /// The adb serial of this emulator, `emulator-<console port>`.
96    pub fn serial(&self) -> Option<String> {
97        self.console_port.map(|p| format!("emulator-{p}"))
98    }
99
100    /// The controller endpoint on loopback, when the file names a gRPC port.
101    pub fn grpc_endpoint(&self) -> Option<String> {
102        self.grpc_port.map(|p| format!("http://127.0.0.1:{p}"))
103    }
104
105    /// The AVD's directory (`avd.dir`), which holds its `config.ini`.
106    pub fn avd_dir(&self) -> Option<PathBuf> {
107        self.entries
108            .get("avd.dir")
109            .filter(|v| !v.is_empty())
110            .map(PathBuf::from)
111    }
112}
113
114/// The pid in a discovery file name: `pid_59226.ini` gives 59226.
115pub fn pid_from_file_name(name: &str) -> Option<u32> {
116    name.strip_prefix("pid_")?
117        .strip_suffix(".ini")?
118        .parse()
119        .ok()
120}
121
122/// The console port in an emulator serial: `emulator-5554` gives 5554.
123pub fn console_port_from_serial(serial: &str) -> Option<u16> {
124    serial.strip_prefix("emulator-")?.parse().ok()
125}
126
127/// The discovery whose console port matches `serial`. When several match, as
128/// when a crashed emulator left a stale file next to the live one, the last one
129/// in `found` wins; [`scan_dir`] orders files oldest first.
130pub fn find_for_serial<'a>(found: &'a [Discovery], serial: &str) -> Option<&'a Discovery> {
131    let port = console_port_from_serial(serial)?;
132    found.iter().rev().find(|d| d.console_port == Some(port))
133}
134
135/// Directories the emulator writes discovery files to, in search order:
136/// `$XDG_RUNTIME_DIR/avd/running` when set, then the per-OS default
137/// (`~/Library/Caches/TemporaryItems/avd/running` on macOS,
138/// `/run/user/<uid>/avd/running` on Linux, `%LOCALAPPDATA%\Temp\avd\running` on
139/// Windows), then `avd/running` under the temporary directory. For one
140/// emulator the authoritative answer is its console command `avd discoverypath`.
141pub fn candidate_dirs() -> Vec<PathBuf> {
142    let var = |name: &str| {
143        std::env::var_os(name)
144            .filter(|v| !v.is_empty())
145            .map(PathBuf::from)
146    };
147    let mut dirs = Vec::new();
148    if let Some(runtime) = var("XDG_RUNTIME_DIR") {
149        dirs.push(runtime.join("avd/running"));
150    }
151    if cfg!(target_os = "macos") {
152        dirs.extend(var("HOME").map(|home| home.join("Library/Caches/TemporaryItems/avd/running")));
153    } else if cfg!(windows) {
154        dirs.extend(
155            var("LOCALAPPDATA").map(|local| local.join("Temp").join("avd").join("running")),
156        );
157    }
158    dirs.extend(run_user_dir());
159    dirs.push(std::env::temp_dir().join("avd/running"));
160    dirs.dedup();
161    dirs
162}
163
164/// `/run/user/<uid>/avd/running`, the Linux default without `XDG_RUNTIME_DIR`.
165/// The process's uid is the owner of `/proc/self`.
166#[cfg(target_os = "linux")]
167fn run_user_dir() -> Option<PathBuf> {
168    use std::os::unix::fs::MetadataExt;
169    let uid = std::fs::metadata("/proc/self").ok()?.uid();
170    Some(PathBuf::from(format!("/run/user/{uid}/avd/running")))
171}
172
173#[cfg(not(target_os = "linux"))]
174fn run_user_dir() -> Option<PathBuf> {
175    None
176}
177
178/// One discovery file found on disk.
179#[derive(Debug)]
180pub struct Found {
181    pub path: PathBuf,
182    pub modified: Option<SystemTime>,
183    pub parsed: Result<Discovery, DiscoveryError>,
184}
185
186/// Reads every `pid_*.ini` in `dir`, oldest modification first. A file that
187/// cannot be read, such as one an exiting emulator just removed, is skipped.
188pub fn scan_dir(dir: &Path) -> std::io::Result<Vec<Found>> {
189    let mut found = Vec::new();
190    for entry in std::fs::read_dir(dir)? {
191        let entry = entry?;
192        let Some(pid) = entry.file_name().to_str().and_then(pid_from_file_name) else {
193            continue;
194        };
195        let path = entry.path();
196        let modified = entry.metadata().and_then(|m| m.modified()).ok();
197        let Ok(contents) = std::fs::read_to_string(&path) else {
198            continue;
199        };
200        found.push(Found {
201            parsed: Discovery::parse(&contents, Some(pid)),
202            path,
203            modified,
204        });
205    }
206    found.sort_by_key(|f| f.modified);
207    Ok(found)
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use std::fs;
214    use std::time::Duration;
215
216    /// The layout emulator 37.1.11 writes, with a placeholder token that has
217    /// base64 padding like the real one.
218    const REAL_LAYOUT: &str = "emulator.build=15917651\n\
219avd.id=pixel_api36\n\
220emulator.version=37.1.11.0\n\
221port.serial=5554\n\
222port.adb=5555\n\
223avd.name=pixel_api36\n\
224avd.dir=/placeholder/.android/avd/pixel_api36.avd\n\
225cmdline=\"/placeholder/emulator/qemu/darwin-aarch64/qemu-system-aarch64-headless\" \"-avd\" \"pixel_api36\" \"-grpc\" \"8554\" \"-grpc-use-token\"\n\
226grpc.token=placeholder+token/value==\n\
227launcher.dir=/placeholder/emulator\n\
228grpc.allowlist=/placeholder/emulator/lib/emulator_access.json\n\
229grpc.port=8554\n";
230
231    /// What an emulator started without `-grpc` writes, when it writes a file.
232    const NO_GRPC: &str = "emulator.version=37.1.11.0\n\
233port.serial=5556\n\
234port.adb=5557\n\
235avd.name=pixel_api36\n";
236
237    /// An empty directory of its own under the system temporary directory.
238    fn temp_dir(tag: &str) -> PathBuf {
239        let dir = std::env::temp_dir().join(format!(
240            "mobile-agent-discovery-{tag}-{}",
241            std::process::id()
242        ));
243        let _ = fs::remove_dir_all(&dir);
244        fs::create_dir_all(&dir).unwrap();
245        dir
246    }
247
248    #[test]
249    fn parses_the_real_layout() {
250        let d = Discovery::parse(REAL_LAYOUT, Some(59226)).unwrap();
251        assert_eq!(d.pid, Some(59226));
252        assert_eq!(d.console_port, Some(5554));
253        assert_eq!(d.adb_port, Some(5555));
254        assert_eq!(d.avd_name.as_deref(), Some("pixel_api36"));
255        assert_eq!(d.emulator_version.as_deref(), Some("37.1.11.0"));
256        assert_eq!(d.grpc_port, Some(8554));
257        assert_eq!(d.entries.len(), 12);
258        assert_eq!(
259            d.avd_dir(),
260            Some(PathBuf::from("/placeholder/.android/avd/pixel_api36.avd"))
261        );
262    }
263
264    #[test]
265    fn the_token_keeps_its_equals_signs() {
266        let d = Discovery::parse(REAL_LAYOUT, None).unwrap();
267        assert_eq!(d.grpc_token.as_deref(), Some("placeholder+token/value=="));
268    }
269
270    #[test]
271    fn quoted_values_are_kept_verbatim() {
272        let d = Discovery::parse(REAL_LAYOUT, None).unwrap();
273        assert!(d.entries["cmdline"].starts_with("\"/placeholder/emulator/qemu/"));
274        assert!(d.entries["cmdline"].ends_with("\"-grpc-use-token\""));
275    }
276
277    #[test]
278    fn a_missing_grpc_port_means_no_endpoint() {
279        let d = Discovery::parse(NO_GRPC, Some(1)).unwrap();
280        assert_eq!(d.grpc_port, None);
281        assert_eq!(d.grpc_token, None);
282        assert_eq!(d.grpc_endpoint(), None);
283        assert_eq!(d.serial().as_deref(), Some("emulator-5556"));
284        assert_eq!(d.avd_dir(), None);
285    }
286
287    #[test]
288    fn a_grpc_port_without_a_token_is_an_open_endpoint() {
289        let d = Discovery::parse("port.serial=5554\ngrpc.port=8554\ngrpc.token=\n", None).unwrap();
290        assert_eq!(d.grpc_endpoint().as_deref(), Some("http://127.0.0.1:8554"));
291        assert_eq!(d.grpc_token, None);
292    }
293
294    #[test]
295    fn crlf_blank_and_comment_lines_are_tolerated() {
296        let text = "; written on Windows\r\n\r\nport.serial=5554\r\n# note\r\ngrpc.port=8554\r\ngrpc.token=abc=\r\n";
297        let d = Discovery::parse(text, None).unwrap();
298        assert_eq!(d.console_port, Some(5554));
299        assert_eq!(d.grpc_port, Some(8554));
300        assert_eq!(d.grpc_token.as_deref(), Some("abc="));
301        assert_eq!(d.entries.len(), 3);
302    }
303
304    #[test]
305    fn a_bad_port_is_an_error() {
306        let err = Discovery::parse("port.serial=5554\ngrpc.port=85x4\n", None).unwrap_err();
307        assert_eq!(
308            err,
309            DiscoveryError::BadPort {
310                key: "grpc.port".into(),
311                value: "85x4".into()
312            }
313        );
314    }
315
316    #[test]
317    fn the_pid_comes_from_the_file_name() {
318        assert_eq!(pid_from_file_name("pid_59226.ini"), Some(59226));
319        assert_eq!(pid_from_file_name("pid_.ini"), None);
320        assert_eq!(pid_from_file_name("pid_12.txt"), None);
321        assert_eq!(pid_from_file_name("pid_12_info.ini"), None);
322        assert_eq!(pid_from_file_name("emulator.ini"), None);
323    }
324
325    #[test]
326    fn a_serial_maps_to_its_console_port() {
327        assert_eq!(console_port_from_serial("emulator-5554"), Some(5554));
328        assert_eq!(console_port_from_serial("emulator-"), None);
329        assert_eq!(console_port_from_serial("R58M123ABC"), None);
330        let d = Discovery::parse(REAL_LAYOUT, None).unwrap();
331        assert_eq!(d.serial().as_deref(), Some("emulator-5554"));
332        assert_eq!(d.grpc_endpoint().as_deref(), Some("http://127.0.0.1:8554"));
333    }
334
335    #[test]
336    fn find_for_serial_matches_the_console_port_and_prefers_the_newest() {
337        let a = Discovery::parse(REAL_LAYOUT, Some(1)).unwrap();
338        let b = Discovery::parse(NO_GRPC, Some(2)).unwrap();
339        let stale = Discovery::parse("port.serial=5554\ngrpc.port=9999\n", Some(3)).unwrap();
340        let found = vec![stale, b.clone(), a.clone()];
341        assert_eq!(find_for_serial(&found, "emulator-5556"), Some(&b));
342        assert_eq!(find_for_serial(&found, "emulator-5554"), Some(&a));
343        assert_eq!(find_for_serial(&found, "emulator-5558"), None);
344        assert_eq!(find_for_serial(&found, "R58M123ABC"), None);
345    }
346
347    /// Writes `contents` to `dir/name`, modified `age` ago.
348    fn write_aged(dir: &Path, name: &str, contents: &str, age: Duration) {
349        let path = dir.join(name);
350        fs::write(&path, contents).unwrap();
351        let file = fs::File::options().write(true).open(&path).unwrap();
352        file.set_modified(SystemTime::now() - age).unwrap();
353    }
354
355    #[test]
356    fn scan_dir_reads_pid_files_oldest_first() {
357        let dir = temp_dir("scan");
358        let secs = Duration::from_secs;
359        write_aged(
360            &dir,
361            "pid_1.ini",
362            "port.serial=5554\ngrpc.port=9999\n",
363            secs(30),
364        );
365        write_aged(&dir, "notes.txt", "port.serial=5554\n", secs(25));
366        write_aged(
367            &dir,
368            "pid_3.ini",
369            "port.serial=5554\ngrpc.port=bad\n",
370            secs(20),
371        );
372        write_aged(&dir, "pid_2.ini", REAL_LAYOUT, secs(10));
373        let found = scan_dir(&dir).unwrap();
374        fs::remove_dir_all(&dir).unwrap();
375
376        let names: Vec<_> = found
377            .iter()
378            .map(|f| f.path.file_name().unwrap().to_string_lossy().into_owned())
379            .collect();
380        assert_eq!(names, ["pid_1.ini", "pid_3.ini", "pid_2.ini"]);
381        let parsed: Vec<Discovery> = found.into_iter().filter_map(|f| f.parsed.ok()).collect();
382        assert_eq!(
383            parsed.len(),
384            2,
385            "the file with a bad port is reported, not parsed"
386        );
387        let newest = find_for_serial(&parsed, "emulator-5554").unwrap();
388        assert_eq!((newest.pid, newest.grpc_port), (Some(2), Some(8554)));
389    }
390
391    #[test]
392    fn the_candidate_dirs_end_with_the_temporary_directory() {
393        let dirs = candidate_dirs();
394        assert!(dirs.len() >= 2, "{dirs:?}");
395        assert_eq!(dirs.last(), Some(&std::env::temp_dir().join("avd/running")));
396        if cfg!(target_os = "macos") {
397            assert!(
398                dirs.iter()
399                    .any(|d| d.ends_with("Library/Caches/TemporaryItems/avd/running")),
400                "{dirs:?}"
401            );
402        }
403    }
404}