Skip to main content

android/agent/
launch.rs

1//! How the agent is started and recognized on the device: its per-launch
2//! socket name, the shell command that starts it detached, the lines it
3//! prints once it listens or when UI automation is taken, and its entries in
4//! the process list.
5
6use std::fmt;
7use std::io::Read;
8use std::time::Duration;
9
10/// The class `app_process` starts.
11pub const MAIN_CLASS: &str = "dev.mobileagent.agent.Main";
12
13/// The name the agent gives itself in the process list, followed by its
14/// socket name: `mobile-agent <socket>`.
15pub const PROCESS_NAME: &str = "mobile-agent";
16
17/// The environment variable that carries the per-launch token, which keeps
18/// it out of the process list.
19pub const TOKEN_VARIABLE: &str = "MOBILE_AGENT_TOKEN";
20
21/// Mode of the pushed jar: read-only for everyone. adbd copies the owner's
22/// bits to group and other, so 0o444 is the one mode that arrives unwidened
23/// and never writable (task 5.1 measured 0o644 arriving as 0666).
24pub const JAR_MODE: u32 = 0o444;
25
26/// Lists every process with its command line, where the agent appears as
27/// `mobile-agent <socket>`.
28pub const PS_COMMAND: &str = "ps -A -o PID,ARGS";
29
30/// Where the jar of the launch whose socket is `socket` is pushed.
31pub fn remote_jar(socket: &str) -> String {
32    format!("/data/local/tmp/{socket}.jar")
33}
34
35/// `bytes` random bytes from the operating system as lowercase hex.
36pub fn random_hex(bytes: usize) -> String {
37    let mut buf = vec![0u8; bytes];
38    let from_os = std::fs::File::open("/dev/urandom")
39        .and_then(|mut f| f.read_exact(&mut buf))
40        .is_ok();
41    if !from_os {
42        // Hosts without /dev/urandom: std seeds its hash keys from the
43        // operating system's random source.
44        use std::hash::{BuildHasher, Hasher};
45        for chunk in buf.chunks_mut(8) {
46            let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
47            hasher.write_u128(
48                std::time::SystemTime::now()
49                    .duration_since(std::time::UNIX_EPOCH)
50                    .map_or(0, |d| d.as_nanos()),
51            );
52            chunk.copy_from_slice(&hasher.finish().to_le_bytes()[..chunk.len()]);
53        }
54    }
55    buf.iter().map(|b| format!("{b:02x}")).collect()
56}
57
58/// `text` with every character a socket name cannot hold replaced by `_`.
59fn socket_safe(text: &str) -> String {
60    text.chars()
61        .map(|c| {
62            if c.is_ascii_alphanumeric() || "._-".contains(c) {
63                c
64            } else {
65                '_'
66            }
67        })
68        .collect()
69}
70
71/// `text` as one shell word, in single quotes.
72fn quote(text: &str) -> String {
73    format!("'{}'", text.replace('\'', "'\\''"))
74}
75
76/// A per-launch socket name that also says which agent listens on it:
77/// `mobile-agent-<version>-p<protocol>-<build>-<id>`. The process list shows
78/// it, so the agent state of a target is known without connecting, which
79/// would take the agent's connection away from whoever holds it. The build
80/// tells apart two builds that report the same version.
81#[derive(Clone, Debug, PartialEq, Eq)]
82pub struct SocketName {
83    /// The agent version, with characters a socket name cannot hold replaced
84    /// by `_`.
85    pub version: String,
86    pub protocol: u16,
87    /// The build the agent was started from ([`crate::agent::AgentJar::build`]).
88    pub build: String,
89    /// Random, unique per launch.
90    pub id: String,
91}
92
93impl SocketName {
94    /// A name for a launch of `version` speaking `protocol` from `build`,
95    /// with `id`.
96    pub fn new(version: &str, protocol: u16, build: &str, id: &str) -> SocketName {
97        SocketName {
98            version: socket_safe(version),
99            protocol,
100            build: socket_safe(build),
101            id: socket_safe(id),
102        }
103    }
104
105    /// A name for a new launch of `version` speaking `protocol` from
106    /// `build`, with a random id.
107    pub fn generate(version: &str, protocol: u16, build: &str) -> SocketName {
108        SocketName::new(version, protocol, build, &random_hex(6))
109    }
110
111    /// Parses a name this driver made; `None` for any other name.
112    pub fn parse(name: &str) -> Option<SocketName> {
113        let rest = name.strip_prefix("mobile-agent-")?;
114        let mut parts = rest.rsplitn(4, '-');
115        let id = parts.next()?;
116        let build = parts.next()?;
117        let protocol = parts.next()?.strip_prefix('p')?;
118        let version = parts.next()?;
119        let word = |text: &str| !text.is_empty() && text.chars().all(|c| c.is_ascii_alphanumeric());
120        let valid = word(id)
121            && word(build)
122            && !protocol.is_empty()
123            && protocol.chars().all(|c| c.is_ascii_digit())
124            && !version.is_empty();
125        if !valid {
126            return None;
127        }
128        Some(SocketName {
129            version: version.to_owned(),
130            protocol: protocol.parse().ok()?,
131            build: build.to_owned(),
132            id: id.to_owned(),
133        })
134    }
135
136    /// Whether this name says the agent is `version` speaking `protocol`,
137    /// started from `build`.
138    pub fn is(&self, version: &str, protocol: u16, build: &str) -> bool {
139        self.version == socket_safe(version)
140            && self.protocol == protocol
141            && self.build == socket_safe(build)
142    }
143}
144
145impl fmt::Display for SocketName {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        write!(
148            f,
149            "mobile-agent-{}-p{}-{}-{}",
150            self.version, self.protocol, self.build, self.id
151        )
152    }
153}
154
155/// The shell command that starts the agent from the jar at `jar`, listening
156/// on `socket`, and returns at once, leaving it running.
157///
158/// - The token is read from the command's standard input, so it appears in
159///   no command line; the caller writes it followed by a newline.
160/// - The agent is started in the background of a subshell, so it is
161///   reparented to init, and dropping the stream that launched it leaves it
162///   running: adbd sends SIGHUP to the shell it started, not to the agent,
163///   and the agent's later writes to the closed stdout fail with EPIPE,
164///   since ART blocks SIGPIPE. The command ignores both signals as well, in
165///   case an adbd signals the whole process group.
166/// - Its standard input is `/dev/null`; standard output and error stay on
167///   the stream, which carries the ready or conflict line.
168/// - `--idle-exit-ms` bounds how long it outlives a host that went away,
169///   `--force` takes UI automation over from a known automation client, and
170///   `extra` arguments are passed on quoted.
171pub fn launch_command(
172    jar: &str,
173    socket: &str,
174    idle_exit: Duration,
175    force: bool,
176    extra: &[String],
177) -> String {
178    let mut command = format!(
179        "read -r t && (trap '' HUP PIPE; {TOKEN_VARIABLE}=\"$t\" CLASSPATH={} exec app_process / \
180         {MAIN_CLASS} serve --socket {} --idle-exit-ms {}",
181        quote(jar),
182        quote(socket),
183        idle_exit.as_millis()
184    );
185    if force {
186        command.push_str(" --force");
187    }
188    for arg in extra {
189        command.push(' ');
190        command.push_str(&quote(arg));
191    }
192    command.push_str(" </dev/null &)");
193    command
194}
195
196/// The `key=value` words of a line, up to `stop` if it is given, which
197/// starts a value that runs to the end of the line.
198fn fields<'a>(line: &'a str, stop: Option<&str>) -> (Vec<(&'a str, &'a str)>, Option<&'a str>) {
199    let (head, tail) = match stop.and_then(|key| line.split_once(&format!(" {key}="))) {
200        Some((head, tail)) => (head, Some(tail)),
201        None => (line, None),
202    };
203    let words = head
204        .split(' ')
205        .filter_map(|word| word.split_once('='))
206        .collect();
207    (words, tail)
208}
209
210fn field<'a>(words: &[(&'a str, &'a str)], key: &str) -> Option<&'a str> {
211    words.iter().find(|(k, _)| *k == key).map(|(_, v)| *v)
212}
213
214/// A value the agent writes as `-` when it has none.
215fn present(value: Option<&str>) -> Option<&str> {
216    value.filter(|v| !v.is_empty() && *v != "-")
217}
218
219/// A process named in an agent's report: `uiautomator` and its pid.
220#[derive(Clone, Debug, PartialEq, Eq)]
221pub struct Holder {
222    /// A short name such as `uiautomator`, `mobile-agent` or `am-instrument`.
223    pub name: String,
224    pub pid: Option<u32>,
225}
226
227impl fmt::Display for Holder {
228    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
229        match self.pid {
230            Some(pid) => write!(f, "{} (pid {pid})", self.name),
231            None => f.write_str(&self.name),
232        }
233    }
234}
235
236/// The line the agent prints once it holds UI automation and listens:
237/// `ready socket=<name> pid=<pid> uid=<uid> protocol=<n> capabilities=0x<bits>
238/// ownership=held connect_ms=<ms> window_wait_ms=<ms> windows=<n>
239/// active=<package> forced=<name:pid or ->`.
240#[derive(Clone, Debug, PartialEq, Eq)]
241pub struct ReadyLine {
242    pub socket: String,
243    pub pid: u32,
244    pub uid: Option<u32>,
245    pub protocol: Option<u16>,
246    pub capabilities: Option<u32>,
247    /// How long taking UI automation took on the device.
248    pub connect_ms: Option<u64>,
249    /// How long the agent waited for an active window.
250    pub window_wait_ms: Option<u64>,
251    pub windows: Option<u32>,
252    /// The package of the active window, when there was one.
253    pub active: Option<String>,
254    /// The holder `--force` stopped to take UI automation over.
255    pub forced: Option<Holder>,
256}
257
258/// Parses a ready line; `None` for any other line.
259pub fn parse_ready(line: &str) -> Option<ReadyLine> {
260    let rest = line.trim_end().strip_prefix("ready ")?;
261    let (words, _) = fields(rest, None);
262    let get = |key| field(&words, key);
263    let number = |key| get(key).and_then(|v: &str| v.parse().ok());
264    Some(ReadyLine {
265        socket: get("socket")?.to_owned(),
266        pid: get("pid")?.parse().ok()?,
267        uid: number("uid"),
268        protocol: get("protocol").and_then(|v| v.parse().ok()),
269        capabilities: get("capabilities")
270            .and_then(|v| u32::from_str_radix(v.trim_start_matches("0x"), 16).ok()),
271        connect_ms: number("connect_ms").map(u64::from),
272        window_wait_ms: number("window_wait_ms").map(u64::from),
273        windows: number("windows"),
274        active: present(get("active")).map(str::to_owned),
275        forced: present(get("forced")).map(|holder| match holder.rsplit_once(':') {
276            Some((name, pid)) => Holder {
277                name: name.to_owned(),
278                pid: pid.parse().ok(),
279            },
280            None => Holder {
281                name: holder.to_owned(),
282                pid: None,
283            },
284        }),
285    })
286}
287
288/// The line the agent prints before exiting with status 3 when another
289/// process holds UI automation: `conflict pid=<pid> holder=<name>
290/// holder_pid=<pid or -> message=<text>`.
291#[derive(Clone, Debug, PartialEq, Eq)]
292pub struct ConflictLine {
293    /// The agent that could not take the role.
294    pub agent_pid: Option<u32>,
295    /// The likely holder, when the process and instrumentation lists name
296    /// one.
297    pub holder: Option<Holder>,
298    /// The agent's explanation, to the end of the line.
299    pub message: String,
300}
301
302/// Parses a conflict line; `None` for any other line.
303pub fn parse_conflict(line: &str) -> Option<ConflictLine> {
304    let rest = line.trim_end().strip_prefix("conflict ")?;
305    let (words, message) = fields(rest, Some("message"));
306    let holder = present(field(&words, "holder")).map(|name| Holder {
307        name: name.to_owned(),
308        pid: present(field(&words, "holder_pid")).and_then(|p| p.parse().ok()),
309    });
310    Some(ConflictLine {
311        agent_pid: field(&words, "pid").and_then(|p| p.parse().ok()),
312        holder,
313        message: message.unwrap_or_default().to_owned(),
314    })
315}
316
317/// An agent process found in the process list.
318#[derive(Clone, Debug, PartialEq, Eq)]
319pub struct RunningAgent {
320    pub pid: u32,
321    /// The socket it listens on.
322    pub socket: String,
323    /// The socket name parsed, when this driver made it; it says the agent's
324    /// version and protocol.
325    pub name: Option<SocketName>,
326}
327
328/// The agents in [`PS_COMMAND`]'s output: processes named `mobile-agent
329/// <socket>`, and `app_process` running the agent's main class where the
330/// process could not rename itself. Wrappers such as `timeout` and the shell
331/// that started them are left out.
332pub fn parse_agents(ps: &str) -> Vec<RunningAgent> {
333    ps.lines()
334        .filter_map(|line| {
335            let (pid, args) = line.trim().split_once(char::is_whitespace)?;
336            let pid: u32 = pid.parse().ok()?;
337            let mut words = args.split_whitespace();
338            let first = words.next()?;
339            let socket = if first == PROCESS_NAME {
340                words.next()?
341            } else if first
342                .rsplit('/')
343                .next()
344                .is_some_and(|program| program.starts_with("app_process"))
345                && args.contains(MAIN_CLASS)
346            {
347                let mut rest = args.split_whitespace().skip_while(|w| *w != "--socket");
348                rest.next()?;
349                rest.next()?
350            } else {
351                return None;
352            };
353            Some(RunningAgent {
354                pid,
355                socket: socket.to_owned(),
356                name: SocketName::parse(socket),
357            })
358        })
359        .collect()
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    const BUILD: &str = "0123456789ab";
367
368    #[test]
369    fn a_socket_name_carries_the_agent_version_protocol_and_build() {
370        let name = SocketName::new("0.1.0", 1, BUILD, "3f9a1c2b7d4e");
371        assert_eq!(
372            name.to_string(),
373            "mobile-agent-0.1.0-p1-0123456789ab-3f9a1c2b7d4e"
374        );
375        assert_eq!(SocketName::parse(&name.to_string()), Some(name.clone()));
376        assert!(name.is("0.1.0", 1, BUILD));
377        assert!(!name.is("0.1.1", 1, BUILD));
378        assert!(!name.is("0.1.0", 2, BUILD));
379        assert!(!name.is("0.1.0", 1, "ba9876543210"), "another build");
380    }
381
382    #[test]
383    fn versions_with_hyphens_and_build_metadata_survive_the_name() {
384        let name = SocketName::new("1.2.0-rc.1+abc", 12, "00aa", "00ff");
385        let text = name.to_string();
386        assert_eq!(text, "mobile-agent-1.2.0-rc.1_abc-p12-00aa-00ff");
387        assert!(
388            text.chars()
389                .all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)),
390            "{text}"
391        );
392        let parsed = SocketName::parse(&text).unwrap();
393        assert!(parsed.is("1.2.0-rc.1+abc", 12, "00aa"));
394        assert_eq!(parsed.version, "1.2.0-rc.1_abc");
395    }
396
397    #[test]
398    fn names_other_launchers_chose_do_not_parse() {
399        for other in [
400            "mobile-agent-3f9a1c2b7d4e",
401            "mobile-agent-it-foo-1-2",
402            "mobile-agent-0.1.0-px-00aa-1a",
403            "scrcpy",
404            "mobile-agent--p1-00aa-1a",
405            // A name without a build, from a driver before builds were named.
406            "mobile-agent-0.1.0-p1-3f9a1c2b7d4e",
407        ] {
408            assert_eq!(SocketName::parse(other), None, "{other}");
409        }
410    }
411
412    #[test]
413    fn generated_names_differ_and_fit_the_agents_socket_rule() {
414        let a = SocketName::generate("0.1.0", 1, BUILD);
415        let b = SocketName::generate("0.1.0", 1, BUILD);
416        assert_ne!(a, b);
417        let text = a.to_string();
418        assert!(text.len() <= 100, "{text}");
419        assert_eq!(a.id.len(), 12);
420        assert!(a.id.chars().all(|c| c.is_ascii_hexdigit()), "{}", a.id);
421    }
422
423    #[test]
424    fn random_hex_has_twice_the_bytes_in_digits_and_varies() {
425        let a = random_hex(16);
426        assert_eq!(a.len(), 32);
427        assert!(
428            a.chars()
429                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
430        );
431        assert_ne!(a, random_hex(16));
432    }
433
434    #[test]
435    fn the_jar_goes_to_a_per_launch_path_in_data_local_tmp() {
436        assert_eq!(
437            remote_jar("mobile-agent-0.1.0-p1-00ff"),
438            "/data/local/tmp/mobile-agent-0.1.0-p1-00ff.jar"
439        );
440    }
441
442    #[test]
443    fn the_launch_command_keeps_the_token_off_the_command_line_and_detaches() {
444        let command = launch_command(
445            "/data/local/tmp/a.jar",
446            "mobile-agent-0.1.0-p1-00ff",
447            Duration::from_secs(30),
448            false,
449            &[],
450        );
451        // The token comes from standard input into a variable.
452        assert!(command.starts_with("read -r t && "), "{command}");
453        assert!(command.contains("MOBILE_AGENT_TOKEN=\"$t\""), "{command}");
454        // SIGHUP and SIGPIPE ignored, reparented to init, stdin from /dev/null.
455        assert!(command.contains("trap '' HUP PIPE"), "{command}");
456        assert!(command.contains("</dev/null &)"), "{command}");
457        assert!(
458            command.contains(
459                "CLASSPATH='/data/local/tmp/a.jar' exec app_process / \
460                 dev.mobileagent.agent.Main serve --socket 'mobile-agent-0.1.0-p1-00ff' \
461                 --idle-exit-ms 30000"
462            ),
463            "{command}"
464        );
465        assert!(!command.contains("--force"), "{command}");
466    }
467
468    #[test]
469    fn force_and_extra_arguments_are_passed_quoted() {
470        let command = launch_command(
471            "/data/local/tmp/a.jar",
472            "s",
473            Duration::from_millis(1500),
474            true,
475            &["--trace".to_owned(), "it's".to_owned()],
476        );
477        assert!(
478            command.contains("--idle-exit-ms 1500 --force '--trace' 'it'\\''s' </dev/null &)"),
479            "{command}"
480        );
481    }
482
483    #[test]
484    fn a_ready_line_gives_the_pid_socket_and_forced_holder() {
485        let line = "ready socket=mobile-agent-0.1.0-p1-00ff pid=812 uid=2000 protocol=1 \
486                    capabilities=0x37 ownership=held connect_ms=12 window_wait_ms=66 windows=2 \
487                    active=com.google.android.apps.nexuslauncher forced=-";
488        let ready = parse_ready(line).unwrap();
489        assert_eq!(
490            ready,
491            ReadyLine {
492                socket: "mobile-agent-0.1.0-p1-00ff".into(),
493                pid: 812,
494                uid: Some(2000),
495                protocol: Some(1),
496                capabilities: Some(0x37),
497                connect_ms: Some(12),
498                window_wait_ms: Some(66),
499                windows: Some(2),
500                active: Some("com.google.android.apps.nexuslauncher".into()),
501                forced: None,
502            }
503        );
504        let forced = parse_ready(
505            "ready socket=s pid=9 uid=2000 protocol=1 capabilities=0x37 ownership=held \
506             connect_ms=1 window_wait_ms=0 windows=1 active=- forced=uiautomator:4702",
507        )
508        .unwrap();
509        assert_eq!(forced.active, None);
510        assert_eq!(
511            forced.forced,
512            Some(Holder {
513                name: "uiautomator".into(),
514                pid: Some(4702)
515            })
516        );
517        assert_eq!(
518            parse_ready("ready socket=s"),
519            None,
520            "a ready line needs a pid"
521        );
522        assert_eq!(parse_ready("warning cannot rename the process"), None);
523    }
524
525    #[test]
526    fn a_conflict_line_names_the_holder_and_keeps_the_whole_message() {
527        let line = "conflict pid=4711 holder=uiautomator holder_pid=4702 message=UI automation \
528                    is held by another process, likely uiautomator (pid 4702: uiautomator); stop \
529                    it, or run the agent with --force to take the role over";
530        let conflict = parse_conflict(line).unwrap();
531        assert_eq!(conflict.agent_pid, Some(4711));
532        assert_eq!(
533            conflict.holder,
534            Some(Holder {
535                name: "uiautomator".into(),
536                pid: Some(4702)
537            })
538        );
539        assert!(
540            conflict
541                .message
542                .starts_with("UI automation is held by another process")
543        );
544        assert!(conflict.message.ends_with("to take the role over"));
545
546        let unknown = parse_conflict(
547            "conflict pid=5 holder=- holder_pid=- message=UI automation is held by a process",
548        )
549        .unwrap();
550        assert_eq!(unknown.holder, None);
551        let instrumentation =
552            parse_conflict("conflict pid=5 holder=instrumentation holder_pid=- message=held")
553                .unwrap();
554        assert_eq!(
555            instrumentation.holder,
556            Some(Holder {
557                name: "instrumentation".into(),
558                pid: None
559            })
560        );
561        assert_eq!(parse_conflict("ready socket=s pid=1"), None);
562    }
563
564    #[test]
565    fn agents_are_found_in_the_process_list_by_name_or_main_class() {
566        let ps = "  PID ARGS\n\
567                  \x20   1 init second_stage\n\
568                  \x20 812 mobile-agent mobile-agent-0.1.0-p1-0123456789ab-00ff\n\
569                  \x20 900 mobile-agent mobile-agent-3f9a1c2b7d4e\n\
570                  \x20 901 app_process / dev.mobileagent.agent.Main serve --socket s1 --idle-exit-ms 10\n\
571                  \x20 902 timeout -s KILL 600 app_process / dev.mobileagent.agent.Main serve --socket s2\n\
572                  \x20 903 sh -c read -r t && (trap '' HUP PIPE; exec app_process / dev.mobileagent.agent.Main serve --socket s3 &)\n\
573                  \x20 904 uiautomator events\n\
574                  \x20 905 grep mobile-agent\n";
575        let agents = parse_agents(ps);
576        assert_eq!(
577            agents,
578            vec![
579                RunningAgent {
580                    pid: 812,
581                    socket: "mobile-agent-0.1.0-p1-0123456789ab-00ff".into(),
582                    name: Some(SocketName::new("0.1.0", 1, BUILD, "00ff")),
583                },
584                RunningAgent {
585                    pid: 900,
586                    socket: "mobile-agent-3f9a1c2b7d4e".into(),
587                    name: None,
588                },
589                RunningAgent {
590                    pid: 901,
591                    socket: "s1".into(),
592                    name: None,
593                },
594            ]
595        );
596    }
597}