1use std::fmt;
7use std::io::Read;
8use std::time::Duration;
9
10pub const MAIN_CLASS: &str = "dev.mobileagent.agent.Main";
12
13pub const PROCESS_NAME: &str = "mobile-agent";
16
17pub const TOKEN_VARIABLE: &str = "MOBILE_AGENT_TOKEN";
20
21pub const JAR_MODE: u32 = 0o444;
25
26pub const PS_COMMAND: &str = "ps -A -o PID,ARGS";
29
30pub fn remote_jar(socket: &str) -> String {
32 format!("/data/local/tmp/{socket}.jar")
33}
34
35pub 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 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
58fn 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
71fn quote(text: &str) -> String {
73 format!("'{}'", text.replace('\'', "'\\''"))
74}
75
76#[derive(Clone, Debug, PartialEq, Eq)]
82pub struct SocketName {
83 pub version: String,
86 pub protocol: u16,
87 pub build: String,
89 pub id: String,
91}
92
93impl SocketName {
94 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 pub fn generate(version: &str, protocol: u16, build: &str) -> SocketName {
108 SocketName::new(version, protocol, build, &random_hex(6))
109 }
110
111 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 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
155pub 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("e(arg));
191 }
192 command.push_str(" </dev/null &)");
193 command
194}
195
196fn 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
214fn present(value: Option<&str>) -> Option<&str> {
216 value.filter(|v| !v.is_empty() && *v != "-")
217}
218
219#[derive(Clone, Debug, PartialEq, Eq)]
221pub struct Holder {
222 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#[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 pub connect_ms: Option<u64>,
249 pub window_wait_ms: Option<u64>,
251 pub windows: Option<u32>,
252 pub active: Option<String>,
254 pub forced: Option<Holder>,
256}
257
258pub 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#[derive(Clone, Debug, PartialEq, Eq)]
292pub struct ConflictLine {
293 pub agent_pid: Option<u32>,
295 pub holder: Option<Holder>,
298 pub message: String,
300}
301
302pub 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#[derive(Clone, Debug, PartialEq, Eq)]
319pub struct RunningAgent {
320 pub pid: u32,
321 pub socket: String,
323 pub name: Option<SocketName>,
326}
327
328pub 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 "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 assert!(command.starts_with("read -r t && "), "{command}");
453 assert!(command.contains("MOBILE_AGENT_TOKEN=\"$t\""), "{command}");
454 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}