1use eyre::eyre;
2use smol::channel::{Receiver, Sender};
3use smol::io::AsyncWriteExt;
4use smol::spawn;
5use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind};
6use tracing::{debug, error};
7
8use std::ffi::{OsStr, OsString};
9use std::path::Path;
10use std::process::{ExitStatus, Output, Stdio};
11use std::sync::OnceLock;
12use std::time::Duration;
13
14use crate::{
15 android::adb::Adb,
16 android::platform::AndroidAbi,
17 android::toolchain::AndroidSdk,
18 device::{
19 ApplicationExit, Artifact, Device, DeviceEvent, FailToRun, LogLevel, RunOptions, Running,
20 },
21 toolchain::Host,
22 utils::{CommandError, parse_whitespace_separated_u32s},
23};
24
25#[derive(Debug, Clone)]
27struct PanicInfo {
28 payload: String,
29 location: Option<String>,
30}
31
32#[derive(Debug, Clone)]
33enum AndroidRuntimeEvent {
34 Panic(PanicInfo),
35 NativeCrash(String),
36 ActivityFinished,
37}
38
39const ADB_DEVICE_COMMAND_TIMEOUT: Duration = Duration::from_secs(10);
40const ANDROID_ACTIVITY_FINISHED_MARKER: &str = "WATERUI_ACTIVITY_FINISHED";
41
42#[derive(Debug, thiserror::Error)]
44enum AdbCommandError {
45 #[error(transparent)]
47 Spawn(#[from] CommandError),
48 #[error("{operation} timed out after {seconds} seconds")]
50 Timeout {
51 operation: String,
53 seconds: u64,
55 },
56 #[error("{operation} failed with status {status}{details}")]
58 Failed {
59 operation: String,
61 status: ExitStatus,
63 details: String,
65 },
66}
67
68async fn run_bounded_adb_output<A, S>(
69 host: &Host,
70 adb: &Adb,
71 args: A,
72 operation: &str,
73) -> Result<Output, AdbCommandError>
74where
75 A: IntoIterator<Item = S>,
76 S: AsRef<OsStr>,
77{
78 let args = args
79 .into_iter()
80 .map(|argument| argument.as_ref().to_os_string())
81 .collect::<Vec<_>>();
82 let operation = operation.to_owned();
83 let command = Box::pin(async move {
84 host.output(adb.path(), &args)
85 .await
86 .map_err(AdbCommandError::from)
87 });
88 let timeout = Box::pin(async move {
89 smol::Timer::after(ADB_DEVICE_COMMAND_TIMEOUT).await;
90 Err(AdbCommandError::Timeout {
91 operation,
92 seconds: ADB_DEVICE_COMMAND_TIMEOUT.as_secs(),
93 })
94 });
95
96 match futures_util::future::select(command, timeout).await {
97 futures_util::future::Either::Left((result, _))
98 | futures_util::future::Either::Right((result, _)) => result,
99 }
100}
101
102async fn run_bounded_adb_command<A, S>(
103 host: &Host,
104 adb: &Adb,
105 args: A,
106 operation: &str,
107) -> Result<String, AdbCommandError>
108where
109 A: IntoIterator<Item = S>,
110 S: AsRef<OsStr>,
111{
112 let output = run_bounded_adb_output(host, adb, args, operation).await?;
113 if output.status.success() {
114 return Ok(String::from_utf8_lossy(&output.stdout).to_string());
115 }
116
117 let stderr = String::from_utf8_lossy(&output.stderr);
118 let stdout = String::from_utf8_lossy(&output.stdout);
119 let details = if !stderr.is_empty() {
120 format!("\nstderr:\n{stderr}")
121 } else if !stdout.is_empty() {
122 format!("\nstdout:\n{stdout}")
123 } else {
124 String::new()
125 };
126 Err(AdbCommandError::Failed {
127 operation: operation.to_owned(),
128 status: output.status,
129 details,
130 })
131}
132
133#[derive(Debug)]
135pub struct AndroidDevice {
136 identifier: String,
137 abi: AndroidAbi,
139}
140
141impl AndroidDevice {
142 #[must_use]
144 pub const fn new(identifier: String, abi: AndroidAbi) -> Self {
145 Self { identifier, abi }
146 }
147
148 #[must_use]
150 pub fn identifier(&self) -> &str {
151 &self.identifier
152 }
153
154 #[must_use]
156 pub const fn abi(&self) -> AndroidAbi {
157 self.abi
158 }
159}
160
161impl Device for AndroidDevice {
162 fn name(&self) -> &str {
163 &self.identifier
164 }
165
166 async fn launch(&self, host: &Host) -> eyre::Result<()> {
167 let adb = Adb::locate(host).await?;
168 host.run(adb.path(), ["-s", &self.identifier, "wait-for-device"])
169 .await?;
170 Ok(())
171 }
172
173 async fn run(
174 &self,
175 host: &Host,
176 artifact: Artifact,
177 options: RunOptions,
178 ) -> Result<Running, FailToRun> {
179 run_on_android(host, &self.identifier, artifact, options).await
180 }
181
182 async fn scan(host: &Host) -> eyre::Result<Vec<Self>> {
183 let adb = Adb::locate(host).await?;
184 Self::scan_with_adb(host, &adb).await
185 }
186}
187
188impl AndroidDevice {
189 pub async fn scan_with_adb(host: &Host, adb: &Adb) -> eyre::Result<Vec<Self>> {
195 let output =
196 run_bounded_adb_command(host, adb, ["devices", "-l"], "listing Android devices")
197 .await
198 .map_err(|e| eyre!("Failed to list devices: {e}"))?;
199
200 let mut devices = Vec::new();
201
202 for line in output.lines().skip(1) {
203 let parts: Vec<&str> = line.split_whitespace().collect();
204 if parts.len() >= 2 && parts[1] == "device" {
205 let identifier = parts[0].to_string();
206
207 let abi = run_bounded_adb_command(
209 host,
210 adb,
211 ["-s", &identifier, "shell", "getprop", "ro.product.cpu.abi"],
212 "querying Android device ABI",
213 )
214 .await
215 .map_err(|e| eyre!("Failed to get device ABI: {e}"))?;
216 let abi = abi
217 .trim()
218 .parse::<AndroidAbi>()
219 .map_err(|e| eyre!("Unsupported device ABI: {e}"))?;
220
221 devices.push(Self::new(identifier, abi));
222 }
223 }
224
225 Ok(devices)
226 }
227}
228
229pub trait AndroidAbiProvider {
231 fn android_abi(&self) -> AndroidAbi;
233}
234
235impl AndroidAbiProvider for AndroidDevice {
236 fn android_abi(&self) -> AndroidAbi {
237 self.abi()
238 }
239}
240
241impl AndroidAbiProvider for AndroidEmulator {
242 fn android_abi(&self) -> AndroidAbi {
243 self.expected_abi()
244 }
245}
246
247async fn run_on_android(
257 host: &Host,
258 device_id: &str,
259 artifact: Artifact,
260 options: RunOptions,
261) -> Result<Running, FailToRun> {
262 let adb = Adb::locate(host)
263 .await
264 .map_err(|error| FailToRun::Run(error.into()))?;
265 let env_vars = options
266 .env_vars()
267 .map(|(key, value)| (key.to_string(), value.to_string()))
268 .collect::<Vec<_>>();
269
270 if let Some(port) =
274 crate::web::dev_url_port(env_vars.iter().map(|(k, v)| (k.as_str(), v.as_str())))
275 .map_err(|error| FailToRun::Launch(eyre!("Invalid dev-server handoff: {error}")))?
276 {
277 reverse_dev_server_port(host, &adb, device_id, port).await?;
278 }
279
280 for port in options.forward_tcp_ports() {
283 forward_device_port(host, &adb, device_id, *port).await?;
284 }
285
286 install_android_artifact(host, &adb, device_id, artifact.path()).await?;
287 launch_android_app(
288 host,
289 &adb,
290 build_android_start_args(device_id, &artifact, &env_vars),
291 )
292 .await?;
293
294 let pid = wait_for_app_pid(host, &adb, device_id, artifact.bundle_id()).await?;
296
297 let host_for_kill = host.clone();
298 let adb_for_kill = adb.clone();
299 let device_id_for_kill = device_id.to_string();
300 let bundle_id_for_kill = artifact.bundle_id().to_string();
301
302 let (mut running, sender) = Running::new(move || {
303 spawn_android_force_stop(
304 &host_for_kill,
305 adb_for_kill,
306 device_id_for_kill,
307 bundle_id_for_kill,
308 );
309 });
310 if !options.forward_tcp_ports().is_empty() {
311 running.retain(RemoveForwardsOnDrop {
312 host: host.clone(),
313 adb: adb.clone(),
314 device_id: device_id.to_string(),
315 ports: options.forward_tcp_ports().to_vec(),
316 });
317 }
318
319 spawn_android_runtime_tasks(AndroidRuntimeTaskContext {
320 host,
321 adb: &adb,
322 device_id,
323 bundle_id: artifact.bundle_id(),
324 pid,
325 log_level: options.log_level(),
326 sender,
327 });
328
329 Ok(running)
330}
331
332async fn install_android_artifact(
333 host: &Host,
334 adb: &Adb,
335 device_id: &str,
336 artifact_path: &Path,
337) -> Result<(), FailToRun> {
338 let install_output = host
339 .command(adb.path())
340 .args(["-s", device_id, "install", "-r"])
341 .arg(artifact_path)
342 .stdout(Stdio::piped())
343 .stderr(Stdio::piped())
344 .output()
345 .await
346 .map_err(|error| FailToRun::Install(eyre!("Failed to install APK: {error}")))?;
347
348 if install_output.status.success() {
349 return Ok(());
350 }
351
352 Err(FailToRun::Install(eyre!(
353 "Failed to install APK:\n{}\n{}",
354 String::from_utf8_lossy(&install_output.stdout).trim(),
355 String::from_utf8_lossy(&install_output.stderr).trim(),
356 )))
357}
358
359fn build_android_start_args(
363 device_id: &str,
364 artifact: &Artifact,
365 env_vars: &[(String, String)],
366) -> Vec<String> {
367 let mut start_args = vec![
368 "-s".to_string(),
369 device_id.to_string(),
370 "shell".to_string(),
371 "am".to_string(),
372 "start".to_string(),
373 "-S".to_string(),
374 "-n".to_string(),
375 format!("{}/.MainActivity", artifact.bundle_id()),
376 ];
377
378 for (key, value) in env_vars {
379 start_args.push("--es".to_string());
380 start_args.push(format!("waterui.env.{key}"));
381 start_args.push(value.clone());
382 }
383
384 start_args
385}
386
387async fn forward_device_port(
390 host: &Host,
391 adb: &Adb,
392 device_id: &str,
393 port: u16,
394) -> Result<(), FailToRun> {
395 let _ = host
399 .command(adb.path())
400 .args([
401 "-s",
402 device_id,
403 "forward",
404 "--remove",
405 &format!("tcp:{port}"),
406 ])
407 .stdout(Stdio::null())
408 .stderr(Stdio::null())
409 .output()
410 .await;
411
412 let output = host
413 .command(adb.path())
414 .args([
415 "-s",
416 device_id,
417 "forward",
418 &format!("tcp:{port}"),
419 &format!("tcp:{port}"),
420 ])
421 .stdout(Stdio::piped())
422 .stderr(Stdio::piped())
423 .output()
424 .await
425 .map_err(|error| FailToRun::Launch(eyre!("Failed to run `adb forward`: {error}")))?;
426
427 if output.status.success() {
428 return Ok(());
429 }
430
431 Err(FailToRun::Launch(eyre!(
432 "`adb forward tcp:{port} tcp:{port}` failed:\n{}\n{}",
433 String::from_utf8_lossy(&output.stdout).trim(),
434 String::from_utf8_lossy(&output.stderr).trim(),
435 )))
436}
437
438struct RemoveForwardsOnDrop {
440 host: Host,
441 adb: Adb,
442 device_id: String,
443 ports: Vec<u16>,
444}
445
446impl Drop for RemoveForwardsOnDrop {
447 fn drop(&mut self) {
448 let host = self.host.clone();
449 let adb = self.adb.clone();
450 let device_id = self.device_id.clone();
451 let ports = self.ports.clone();
452 let spawn_result = std::thread::Builder::new()
453 .name("waterui-android-forward-remove".to_string())
454 .spawn(move || {
455 for port in ports {
456 let _ = host
457 .std_command(adb.path())
458 .args([
459 "-s",
460 &device_id,
461 "forward",
462 "--remove",
463 &format!("tcp:{port}"),
464 ])
465 .output();
466 }
467 });
468
469 if let Err(error) = spawn_result {
470 error!("Failed to spawn adb forward cleanup thread: {error}");
471 }
472 }
473}
474
475async fn reverse_dev_server_port(
478 host: &Host,
479 adb: &Adb,
480 device_id: &str,
481 port: u16,
482) -> Result<(), FailToRun> {
483 let output = host
484 .command(adb.path())
485 .args(crate::web::adb_reverse_args(device_id, port))
486 .stdout(Stdio::piped())
487 .stderr(Stdio::piped())
488 .output()
489 .await
490 .map_err(|error| FailToRun::Launch(eyre!("Failed to run `adb reverse`: {error}")))?;
491
492 if output.status.success() {
493 return Ok(());
494 }
495
496 Err(FailToRun::Launch(eyre!(
497 "`adb reverse tcp:{port} tcp:{port}` failed:\n{}\n{}",
498 String::from_utf8_lossy(&output.stdout).trim(),
499 String::from_utf8_lossy(&output.stderr).trim(),
500 )))
501}
502
503async fn launch_android_app(
504 host: &Host,
505 adb: &Adb,
506 start_args: Vec<String>,
507) -> Result<(), FailToRun> {
508 let output = host
509 .command(adb.path())
510 .args(&start_args)
511 .stdout(Stdio::piped())
512 .stderr(Stdio::piped())
513 .output()
514 .await
515 .map_err(|error| FailToRun::Launch(eyre!("Failed to launch app: {error}")))?;
516
517 if output.status.success() {
518 return Ok(());
519 }
520
521 Err(FailToRun::Launch(eyre!(
522 "Failed to launch app:\n{}\n{}",
523 String::from_utf8_lossy(&output.stdout).trim(),
524 String::from_utf8_lossy(&output.stderr).trim(),
525 )))
526}
527
528fn spawn_android_force_stop(host: &Host, adb: Adb, device_id: String, bundle_id: String) {
529 let host = host.clone();
530 let spawn_result = std::thread::Builder::new()
531 .name("waterui-android-force-stop".to_string())
532 .spawn(move || {
533 let result = host
534 .std_command(adb.path())
535 .args(["-s", &device_id, "shell", "am", "force-stop", &bundle_id])
536 .output();
537
538 match result {
539 Ok(output) => {
540 tracing::debug!(
541 "Force-stop command executed: status={}, stdout={}, stderr={}",
542 output.status,
543 String::from_utf8_lossy(&output.stdout),
544 String::from_utf8_lossy(&output.stderr)
545 );
546 }
547 Err(error) => {
548 error!("Failed to stop app {}: {}", bundle_id, error);
549 }
550 }
551 });
552
553 if let Err(error) = spawn_result {
554 error!("Failed to spawn force-stop worker thread: {error}");
555 }
556}
557
558struct AndroidRuntimeTaskContext<'a> {
559 host: &'a Host,
560 adb: &'a Adb,
561 device_id: &'a str,
562 bundle_id: &'a str,
563 pid: u32,
564 log_level: Option<LogLevel>,
565 sender: Sender<DeviceEvent>,
566}
567
568fn spawn_android_runtime_tasks(context: AndroidRuntimeTaskContext<'_>) {
569 let AndroidRuntimeTaskContext {
570 host,
571 adb,
572 device_id,
573 bundle_id,
574 pid,
575 log_level,
576 sender,
577 } = context;
578 let sender_for_monitor = sender.clone();
579 let sender_for_runtime_event = sender.clone();
580 let sender_for_logs = sender;
581 let adb_for_monitor = adb.clone();
582 let host_for_monitor = host.clone();
583 let device_id_for_monitor = device_id.to_string();
584 let bundle_id_for_monitor = bundle_id.to_string();
585
586 smol::spawn(async move {
587 monitor_android_process(
588 host_for_monitor,
589 adb_for_monitor,
590 &device_id_for_monitor,
591 &bundle_id_for_monitor,
592 pid,
593 sender_for_monitor,
594 )
595 .await;
596 })
597 .detach();
598
599 let runtime_event_rx =
600 start_android_log_stream(host, adb, device_id, pid, log_level, sender_for_logs);
601 spawn(async move {
602 if let Ok(event) = runtime_event_rx.recv().await {
603 match event {
604 AndroidRuntimeEvent::Panic(info) => {
605 let _ = sender_for_runtime_event
606 .send(DeviceEvent::Crashed(format_android_panic(&info)))
607 .await;
608 }
609 AndroidRuntimeEvent::NativeCrash(log) => {
610 let _ = sender_for_runtime_event
611 .send(DeviceEvent::Crashed(format!(
612 "Android process crashed.\n\n=== Crash Log ===\n{log}"
613 )))
614 .await;
615 }
616 AndroidRuntimeEvent::ActivityFinished => {
617 let _ = sender_for_runtime_event
618 .send(DeviceEvent::Exited(ApplicationExit::user_closed()))
619 .await;
620 }
621 }
622 }
623 })
624 .detach();
625}
626
627fn format_android_panic(info: &PanicInfo) -> String {
628 let mut msg = format!("Panic: {}", info.payload);
629 if let Some(location) = &info.location {
630 msg.push('\n');
631 msg.push_str(" at ");
632 msg.push_str(location);
633 }
634 msg
635}
636
637async fn wait_for_app_pid(
639 host: &Host,
640 adb: &Adb,
641 device_id: &str,
642 bundle_id: &str,
643) -> Result<u32, FailToRun> {
644 for _ in 0..10 {
645 smol::Timer::after(std::time::Duration::from_millis(200)).await;
646 if let Ok(output) = run_bounded_adb_command(
647 host,
648 adb,
649 ["-s", device_id, "shell", "pidof", bundle_id],
650 "querying the launched Android process",
651 )
652 .await
653 && let Some(pid) = parse_whitespace_separated_u32s(&output).into_iter().next()
654 {
655 return Ok(pid);
656 }
657 }
658
659 let crash_info = match run_bounded_adb_command(
661 host,
662 adb,
663 [
664 "-s",
665 device_id,
666 "logcat",
667 "-d",
668 "-t",
669 "100",
670 "-s",
671 "AndroidRuntime:E",
672 "DEBUG:*",
673 "WaterUI:*",
674 ],
675 "collecting Android startup crash logs",
676 )
677 .await
678 {
679 Ok(output) => output,
680 Err(err) => format!("(failed to collect logcat crash info: {err})"),
681 };
682
683 let mut error_msg = format!("App {bundle_id} crashed on startup (process not found).\n\n");
684
685 if !crash_info.trim().is_empty() {
686 error_msg.push_str("=== Crash Log ===\n");
687 error_msg.push_str(&crash_info);
688 }
689
690 Err(FailToRun::Launch(eyre!("{}", error_msg)))
691}
692
693pub async fn emulator_avd_name_with_adb(
698 host: &Host,
699 adb: &Adb,
700 emulator_id: &str,
701) -> eyre::Result<String> {
702 if !emulator_id.starts_with("emulator-") {
703 eyre::bail!("Not an Android emulator identifier: {emulator_id}");
704 }
705
706 let output = run_bounded_adb_command(
707 host,
708 adb,
709 ["-s", emulator_id, "emu", "avd", "name"],
710 "querying the Android emulator name",
711 )
712 .await?;
713 let name = output.lines().next().unwrap_or_default().trim();
714 if name.is_empty() {
715 eyre::bail!("Failed to query AVD name for {emulator_id}: empty response");
716 }
717 Ok(name.to_string())
718}
719
720pub async fn emulator_avd_name(host: &Host, emulator_id: &str) -> eyre::Result<String> {
725 let adb = Adb::locate(host).await?;
726 emulator_avd_name_with_adb(host, &adb, emulator_id).await
727}
728
729async fn try_find_running_emulator_for_avd(
730 host: &Host,
731 adb: &Adb,
732 avd_name: &str,
733) -> eyre::Result<Option<AndroidDevice>> {
734 let devices = AndroidDevice::scan_with_adb(host, adb).await?;
735
736 for device in devices {
737 let id = device.identifier();
738 if !id.starts_with("emulator-") {
739 continue;
740 }
741
742 match emulator_avd_name_with_adb(host, adb, id).await {
743 Ok(name) if name == avd_name => return Ok(Some(device)),
744 Ok(_) => {}
745 Err(e) => {
746 debug!("Failed to query AVD name for {id}: {e}");
748 }
749 }
750 }
751
752 Ok(None)
753}
754
755async fn adb_emulator_states(host: &Host, adb: &Adb) -> eyre::Result<String> {
756 let output = run_bounded_adb_command(
757 host,
758 adb,
759 ["devices", "-l"],
760 "querying Android emulator state",
761 )
762 .await?;
763 let states: Vec<String> = output
764 .lines()
765 .skip(1)
766 .map(str::trim)
767 .filter(|line| line.starts_with("emulator-"))
768 .map(ToOwned::to_owned)
769 .collect();
770
771 if states.is_empty() {
772 return Ok(String::new());
773 }
774
775 Ok(states.join("; "))
776}
777
778async fn adb_emulator_boot_completed(host: &Host, adb: &Adb, emulator_id: &str) -> bool {
779 run_bounded_adb_command(
780 host,
781 adb,
782 ["-s", emulator_id, "shell", "getprop", "sys.boot_completed"],
783 "querying Android emulator boot completion",
784 )
785 .await
786 .is_ok_and(|value| value.trim() == "1")
787}
788
789fn adb_reports_device_ready(output: &str, device_id: &str) -> bool {
790 output.lines().skip(1).any(|line| {
791 let mut fields = line.split_whitespace();
792 fields.next() == Some(device_id) && fields.next() == Some("device")
793 })
794}
795
796async fn adb_device_is_ready(host: &Host, adb: &Adb, device_id: &str) -> eyre::Result<bool> {
797 let output = run_bounded_adb_command(
798 host,
799 adb,
800 ["devices", "-l"],
801 "querying Android device readiness",
802 )
803 .await?;
804 Ok(adb_reports_device_ready(&output, device_id))
805}
806
807fn command_targets_avd(command: &[OsString], avd_name: &OsStr) -> bool {
808 command
809 .windows(2)
810 .any(|arguments| arguments[0] == "-avd" && arguments[1] == avd_name)
811}
812
813async fn avd_process_is_running(avd_name: &str) -> bool {
814 let avd_name = OsString::from(avd_name);
815 smol::unblock(move || {
816 let mut processes = System::new();
817 processes.refresh_processes_specifics(
818 ProcessesToUpdate::All,
819 true,
820 ProcessRefreshKind::nothing().with_cmd(UpdateKind::Always),
821 );
822 processes
823 .processes()
824 .values()
825 .any(|process| command_targets_avd(process.cmd(), &avd_name))
826 })
827 .await
828}
829
830async fn adb_package_manager_ready(host: &Host, adb: &Adb, emulator_id: &str) -> bool {
831 run_bounded_adb_command(
832 host,
833 adb,
834 ["-s", emulator_id, "shell", "pm", "path", "android"],
835 "querying Android package manager readiness",
836 )
837 .await
838 .is_ok_and(|output| {
839 output
840 .lines()
841 .any(|line| line.trim().starts_with("package:"))
842 })
843}
844
845async fn monitor_android_process(
847 host: Host,
848 adb: Adb,
849 device_id: &str,
850 bundle_id: &str,
851 pid: u32,
852 sender: smol::channel::Sender<DeviceEvent>,
853) {
854 loop {
856 smol::Timer::after(std::time::Duration::from_secs(1)).await;
857
858 let pids = match query_android_process_pids(&host, &adb, device_id, bundle_id).await {
862 Ok(pids) => pids,
863 Err(err) => {
864 if adb_device_is_ready(&host, &adb, device_id)
865 .await
866 .is_ok_and(|ready| !ready)
867 {
868 debug!(
869 "Android device {device_id} disconnected while monitoring {bundle_id}: {err}"
870 );
871 let _ = sender
872 .send(DeviceEvent::Exited(ApplicationExit::user_closed()))
873 .await;
874 break;
875 }
876 debug!(
877 "Failed to query process state via pidof for {bundle_id} on {device_id}: {err}"
878 );
879 continue;
880 }
881 };
882
883 let still_running = pids.contains(&pid);
885
886 if !still_running {
887 let pid_arg = format!("--pid={pid}");
889 let pid_log_args = vec![
890 "-s".to_string(),
891 device_id.to_string(),
892 "logcat".to_string(),
893 "-v".to_string(),
894 "threadtime".to_string(),
895 "-d".to_string(),
896 "-t".to_string(),
897 "200".to_string(),
898 pid_arg,
899 "*:V".to_string(),
900 ];
901 let pid_log = run_bounded_adb_output(
902 &host,
903 &adb,
904 pid_log_args
905 .iter()
906 .map(|s| std::ffi::OsStr::new(s.as_str())),
907 "collecting Android process exit logs",
908 )
909 .await
910 .map_or_else(
911 |err| {
912 debug!("Failed to fetch PID-filtered logcat: {err}");
913 String::new()
914 },
915 |output| {
916 if output.status.success() {
917 String::from_utf8_lossy(&output.stdout).to_string()
918 } else {
919 debug!("PID-filtered logcat exited with status {}", output.status);
920 String::new()
921 }
922 },
923 );
924
925 if android_log_looks_like_crash(&pid_log, bundle_id, pid) {
926 let crash_log = pid_log;
927
928 let error_msg = if crash_log.trim().is_empty() {
929 format!("Process {bundle_id} crashed.")
930 } else {
931 format!("Process {bundle_id} crashed.\n\n=== Crash Log ===\n{crash_log}")
932 };
933
934 let _ = sender.send(DeviceEvent::Crashed(error_msg)).await;
935 } else {
936 let _ = sender
937 .send(DeviceEvent::Exited(ApplicationExit::user_closed()))
938 .await;
939 }
940 break;
941 }
942 }
943}
944
945async fn query_android_process_pids(
946 host: &Host,
947 adb: &Adb,
948 device_id: &str,
949 bundle_id: &str,
950) -> eyre::Result<Vec<u32>> {
951 let output = run_bounded_adb_output(
952 host,
953 adb,
954 ["-s", device_id, "shell", "pidof", bundle_id].map(OsStr::new),
955 "querying Android process state",
956 )
957 .await?;
958 let stdout = String::from_utf8_lossy(&output.stdout);
959 if output.status.success() || stdout.trim().is_empty() {
960 return Ok(parse_whitespace_separated_u32s(&stdout));
961 }
962
963 let stderr = String::from_utf8_lossy(&output.stderr);
964 eyre::bail!(
965 "pidof failed with status {}: {}",
966 output.status,
967 stderr.trim()
968 );
969}
970
971fn log_mentions_pid(log: &str, pid: u32) -> bool {
972 let pid_str = pid.to_string();
973 let pid_lower = format!("pid: {pid}");
974 let pid_upper = format!("PID: {pid}");
975
976 log.lines().any(|line| {
977 line.split_whitespace().any(|part| part == pid_str)
978 || line.contains(&pid_lower)
979 || line.contains(&pid_upper)
980 })
981}
982
983fn android_log_looks_like_crash(log: &str, bundle_id: &str, pid: u32) -> bool {
984 if log.trim().is_empty() {
985 return false;
986 }
987
988 let relevant = log.contains(bundle_id) || log_mentions_pid(log, pid);
989 if !relevant {
990 return false;
991 }
992
993 if android_log_line_looks_like_crash(log) {
994 return true;
995 }
996
997 if !log.contains(bundle_id) {
999 return false;
1000 }
1001
1002 log.contains("AndroidRuntime")
1004 && (log.contains("E AndroidRuntime") || log.contains("Exception"))
1005}
1006
1007fn android_log_line_looks_like_crash(line: &str) -> bool {
1008 line.contains("FATAL EXCEPTION")
1009 || line.contains("Fatal signal")
1010 || line.contains("SIGSEGV")
1011 || line.contains("SIGABRT")
1012 || line.contains("SIGBUS")
1013 || line.contains("SIGILL")
1014 || line.contains("SIGFPE")
1015 || line.contains("Abort message:")
1016 || contains_tombstone_backtrace_marker(line)
1017}
1018
1019fn contains_tombstone_backtrace_marker(line: &str) -> bool {
1023 const MARKER: &str = "backtrace:";
1024 line.match_indices(MARKER)
1025 .any(|(index, _)| line.as_bytes().get(index + MARKER.len()) != Some(&b':'))
1026}
1027
1028fn start_android_log_stream(
1033 host: &Host,
1034 adb: &Adb,
1035 device_id: &str,
1036 pid: u32,
1037 log_level: Option<LogLevel>,
1038 sender: Sender<DeviceEvent>,
1039) -> Receiver<AndroidRuntimeEvent> {
1040 use futures_util::StreamExt;
1041 use futures_util::io::{AsyncBufReadExt, BufReader};
1042
1043 let (runtime_event_tx, runtime_event_rx) = smol::channel::bounded::<AndroidRuntimeEvent>(1);
1045
1046 let priority = match log_level {
1049 Some(LogLevel::Debug) => 'D',
1050 Some(LogLevel::Verbose) => 'V',
1051 Some(LogLevel::Error | LogLevel::Warn | LogLevel::Info) | None => 'I',
1052 };
1053
1054 let pid_arg = format!("--pid={pid}");
1056 let mut cmd = host.command(adb.path());
1057 cmd.args(["-s", device_id, "logcat", "-v", "threadtime"])
1058 .arg(pid_arg)
1059 .arg(format!("*:{priority}"))
1060 .stdout(std::process::Stdio::piped())
1061 .stderr(std::process::Stdio::null());
1062
1063 let mut child = match cmd.spawn() {
1064 Ok(c) => c,
1065 Err(e) => {
1066 tracing::warn!("Failed to spawn logcat: {e}");
1067 return runtime_event_rx;
1068 }
1069 };
1070
1071 let Some(stdout) = child.stdout.take() else {
1072 return runtime_event_rx;
1073 };
1074
1075 let reader = BufReader::new(stdout);
1076 let mut lines = reader.lines();
1077
1078 spawn(async move {
1079 while let Some(result) = lines.next().await {
1082 let Ok(line) = result else { break };
1083
1084 if let Some(event) = android_runtime_event_from_log_line(&line) {
1086 let _ = runtime_event_tx.try_send(event);
1087 }
1088
1089 if let Some(requested_level) = log_level {
1091 let (parsed_level, message) = parse_logcat_line(&line);
1092
1093 if log_level_allows(requested_level, parsed_level)
1094 && sender
1095 .try_send(DeviceEvent::Log {
1096 level: parsed_level,
1097 message,
1098 })
1099 .is_err()
1100 {
1101 break;
1102 }
1103 }
1104 }
1105
1106 let _ = child.kill();
1108 })
1109 .detach();
1110
1111 runtime_event_rx
1112}
1113
1114fn android_runtime_event_from_log_line(line: &str) -> Option<AndroidRuntimeEvent> {
1115 if line.contains("panic.payload=")
1116 && let Some(info) = extract_panic_info_from_log(line)
1117 {
1118 return Some(AndroidRuntimeEvent::Panic(info));
1119 }
1120 if android_log_line_looks_like_crash(line) {
1121 return Some(AndroidRuntimeEvent::NativeCrash(line.to_string()));
1122 }
1123 if line.contains(ANDROID_ACTIVITY_FINISHED_MARKER) {
1124 return Some(AndroidRuntimeEvent::ActivityFinished);
1125 }
1126 None
1127}
1128
1129fn log_level_allows(requested: LogLevel, actual: tracing::Level) -> bool {
1130 match requested {
1131 LogLevel::Error => actual == tracing::Level::ERROR,
1132 LogLevel::Warn => matches!(actual, tracing::Level::ERROR | tracing::Level::WARN),
1133 LogLevel::Info => matches!(
1134 actual,
1135 tracing::Level::ERROR | tracing::Level::WARN | tracing::Level::INFO
1136 ),
1137 LogLevel::Debug => actual != tracing::Level::TRACE,
1138 LogLevel::Verbose => true,
1139 }
1140}
1141
1142fn extract_panic_info_from_log(line: &str) -> Option<PanicInfo> {
1144 let mut payload = None;
1145 let mut location = None;
1146
1147 if let Some(start) = line.find("panic.payload=\"") {
1149 let start = start + 15;
1150 if let Some(end) = line[start..].find('"') {
1151 payload = Some(line[start..start + end].to_string());
1152 }
1153 }
1154
1155 if let Some(start) = line.find("panic.location=\"") {
1157 let start = start + 16;
1158 if let Some(end) = line[start..].find('"') {
1159 location = Some(line[start..start + end].to_string());
1160 }
1161 }
1162
1163 payload.map(|p| PanicInfo {
1164 payload: p,
1165 location,
1166 })
1167}
1168
1169struct LogcatParsed {
1171 level: tracing::Level,
1172 tag: String,
1173 message: String,
1174}
1175
1176fn parse_logcat_line(line: &str) -> (tracing::Level, String) {
1179 if let Some(parsed) = try_parse_logcat(line) {
1181 let formatted = format!("[{}] {}", parsed.tag, parsed.message);
1182 return (parsed.level, formatted);
1183 }
1184
1185 (tracing::Level::INFO, line.to_string())
1187}
1188
1189fn try_parse_logcat(line: &str) -> Option<LogcatParsed> {
1191 let parts: Vec<&str> = line.splitn(7, char::is_whitespace).collect();
1196
1197 if parts.len() < 6 {
1199 return None;
1200 }
1201
1202 let mut level_idx = None;
1204 for (i, part) in parts.iter().enumerate() {
1205 if part.len() == 1 {
1206 let c = part.chars().next()?;
1207 if matches!(c, 'V' | 'D' | 'I' | 'W' | 'E' | 'F') {
1208 level_idx = Some(i);
1209 break;
1210 }
1211 }
1212 }
1213
1214 let level_idx = level_idx?;
1215 if level_idx + 1 >= parts.len() {
1216 return None;
1217 }
1218
1219 let level = match parts[level_idx] {
1220 "E" | "F" => tracing::Level::ERROR,
1221 "W" => tracing::Level::WARN,
1222 "D" => tracing::Level::DEBUG,
1223 "V" => tracing::Level::TRACE,
1224 _ => tracing::Level::INFO,
1225 };
1226
1227 let level_char = parts[level_idx].chars().next()?;
1231 let search_start = 18.min(line.len());
1232 let level_pos = line[search_start..]
1233 .find(level_char)
1234 .map(|p| p + search_start)?;
1235
1236 let after_level = line.get(level_pos + 1..)?.trim_start();
1237
1238 after_level.find(": ").map_or_else(
1240 || {
1241 Some(LogcatParsed {
1242 level,
1243 tag: "unknown".to_string(),
1244 message: after_level.to_string(),
1245 })
1246 },
1247 |colon_pos| {
1248 let tag = after_level[..colon_pos].trim();
1249 let message = after_level[colon_pos + 2..].to_string();
1250 Some(LogcatParsed {
1251 level,
1252 tag: tag.to_string(),
1253 message,
1254 })
1255 },
1256 )
1257}
1258
1259#[derive(Debug)]
1264pub struct AndroidEmulator {
1265 avd_name: String,
1267 expected_abi: AndroidAbi,
1268 device: OnceLock<AndroidDevice>,
1269}
1270
1271impl AndroidEmulator {
1272 pub async fn open(host: &Host, avd_name: String) -> eyre::Result<Self> {
1277 let expected_abi = read_avd_abi(host, &avd_name).await?;
1278 Ok(Self {
1279 avd_name,
1280 expected_abi,
1281 device: OnceLock::new(),
1282 })
1283 }
1284
1285 #[must_use]
1287 pub fn avd_name(&self) -> &str {
1288 &self.avd_name
1289 }
1290
1291 #[must_use]
1292 pub const fn expected_abi(&self) -> AndroidAbi {
1294 self.expected_abi
1295 }
1296}
1297
1298impl Device for AndroidEmulator {
1299 fn name(&self) -> &str {
1300 &self.avd_name
1301 }
1302
1303 async fn launch(&self, host: &Host) -> eyre::Result<()> {
1304 let emulator_path = AndroidSdk::emulator_path(host)
1305 .ok_or_else(|| eyre::eyre!("Android emulator not found"))?;
1306 let adb = Adb::locate(host).await?;
1307
1308 let mut emulator_process = if avd_process_is_running(&self.avd_name).await {
1309 debug!(
1310 "AVD '{}' already has a running emulator process; waiting for it to become ready",
1311 self.avd_name
1312 );
1313 None
1314 } else {
1315 let mut emulator_cmd = host.std_command(&emulator_path);
1318 emulator_cmd
1319 .arg("-avd")
1320 .arg(&self.avd_name)
1321 .arg("-no-snapshot-load")
1322 .stdout(std::process::Stdio::null())
1323 .stderr(std::process::Stdio::null());
1324
1325 #[cfg(unix)]
1326 {
1327 use std::os::unix::process::CommandExt as _;
1328 emulator_cmd.process_group(0);
1331 }
1332
1333 Some(smol::unblock(move || emulator_cmd.spawn()).await?)
1334 };
1335
1336 let start = std::time::Instant::now();
1337 let timeout = std::time::Duration::from_mins(5);
1338 let mut last_emulator_states = String::new();
1339
1340 loop {
1341 if let Some(process) = emulator_process.as_mut()
1342 && let Some(status) = process.try_wait()?
1343 {
1344 if avd_process_is_running(&self.avd_name).await {
1345 debug!(
1346 "Launched emulator process exited with {status}, but another process owns AVD '{}'; waiting for that instance",
1347 self.avd_name
1348 );
1349 emulator_process = None;
1350 } else {
1351 eyre::bail!(
1352 "Emulator process exited before becoming ready (status: {status}). Check AVD configuration and run `emulator -avd {}` manually for details.",
1353 self.avd_name
1354 );
1355 }
1356 } else if emulator_process.is_none() && !avd_process_is_running(&self.avd_name).await {
1357 eyre::bail!(
1358 "Existing emulator process for AVD '{}' exited before becoming ready.",
1359 self.avd_name
1360 );
1361 }
1362
1363 if start.elapsed() > timeout {
1364 let states = if last_emulator_states.is_empty() {
1365 "no emulator device reported by adb".to_string()
1366 } else {
1367 last_emulator_states.clone()
1368 };
1369
1370 eyre::bail!(
1371 "Emulator launch timed out after 300 seconds (ADB state: {}).",
1372 states
1373 );
1374 }
1375
1376 last_emulator_states = match adb_emulator_states(host, &adb).await {
1377 Ok(states) => states,
1378 Err(err) => format!("failed to query emulator state via adb: {err}"),
1379 };
1380
1381 if let Some(device) =
1382 try_find_running_emulator_for_avd(host, &adb, &self.avd_name).await?
1383 {
1384 if device.abi() != self.expected_abi {
1385 eyre::bail!(
1386 "AVD '{}' expected ABI {}, but running emulator reports {}",
1387 self.avd_name,
1388 self.expected_abi.as_str(),
1389 device.abi().as_str()
1390 );
1391 }
1392
1393 let emulator_id = device.identifier().to_string();
1394 let boot_completed = adb_emulator_boot_completed(host, &adb, &emulator_id).await;
1395 let package_ready = adb_package_manager_ready(host, &adb, &emulator_id).await;
1396
1397 if !boot_completed || !package_ready {
1398 debug!(
1399 "Emulator {} detected but not fully ready yet (boot_completed={}, package_ready={})",
1400 emulator_id, boot_completed, package_ready
1401 );
1402 smol::Timer::after(std::time::Duration::from_secs(2)).await;
1403 continue;
1404 }
1405
1406 self.device
1407 .set(device)
1408 .map_err(|_| eyre::eyre!("Emulator device already initialized"))?;
1409 return Ok(());
1410 }
1411
1412 smol::Timer::after(std::time::Duration::from_secs(2)).await;
1413 }
1414 }
1415
1416 async fn run(
1417 &self,
1418 host: &Host,
1419 artifact: Artifact,
1420 options: RunOptions,
1421 ) -> Result<Running, FailToRun> {
1422 let device = self.device.get().ok_or_else(|| {
1423 FailToRun::Run(eyre!(
1424 "Android emulator '{}' is not launched. Launch it before running.",
1425 self.avd_name
1426 ))
1427 })?;
1428 run_on_android(host, device.identifier(), artifact, options).await
1429 }
1430
1431 async fn scan(host: &Host) -> eyre::Result<Vec<Self>> {
1432 let emulator_path = AndroidSdk::emulator_path(host)
1434 .ok_or_else(|| eyre::eyre!("Android emulator not found"))?;
1435
1436 let output = host
1437 .output(&emulator_path, ["-list-avds"])
1438 .await
1439 .map_err(|e| eyre!("Failed to list AVDs: {e}"))?;
1440
1441 if !output.status.success() {
1442 let stderr = String::from_utf8_lossy(&output.stderr);
1443 eyre::bail!("Failed to list AVDs: {}", stderr.trim());
1444 }
1445
1446 let stdout = String::from_utf8_lossy(&output.stdout);
1447 let mut avds = Vec::new();
1448 for name in stdout.lines().map(str::trim).filter(|l| !l.is_empty()) {
1449 avds.push(Self::open(host, name.to_string()).await?);
1450 }
1451
1452 Ok(avds)
1453 }
1454}
1455
1456async fn read_avd_abi(host: &Host, avd_name: &str) -> eyre::Result<AndroidAbi> {
1457 let home = host
1458 .home_dir()
1459 .ok_or_else(|| eyre!("Failed to resolve home directory"))?;
1460 let config_path = home
1461 .join(".android/avd")
1462 .join(format!("{avd_name}.avd"))
1463 .join("config.ini");
1464
1465 let content = smol::fs::read_to_string(&config_path)
1466 .await
1467 .map_err(|e| eyre!("Failed to read AVD config {}: {e}", config_path.display()))?;
1468
1469 let abi_value = content
1470 .lines()
1471 .filter_map(|line| line.split_once('=').map(|(k, v)| (k.trim(), v.trim())))
1472 .find_map(|(k, v)| (k == "abi.type").then_some(v))
1473 .ok_or_else(|| eyre!("AVD config {} missing key abi.type", config_path.display()))?;
1474
1475 abi_value
1476 .parse::<AndroidAbi>()
1477 .map_err(|e| eyre!("Unsupported AVD ABI '{abi_value}': {e}"))
1478}
1479
1480pub async fn screenshot(host: &Host, device_id: &str, output: &Path) -> eyre::Result<()> {
1490 let adb = Adb::locate(host).await?;
1491
1492 let output_result = run_bounded_adb_output(
1493 host,
1494 &adb,
1495 ["-s", device_id, "exec-out", "screencap", "-p"],
1496 "capturing the Android device screen",
1497 )
1498 .await?;
1499
1500 if !output_result.status.success() {
1501 let stderr = String::from_utf8_lossy(&output_result.stderr);
1502 eyre::bail!("Failed to capture screenshot: {}", stderr.trim());
1503 }
1504
1505 let mut file = smol::fs::File::create(output).await?;
1507 file.write_all(&output_result.stdout).await?;
1508 file.flush().await?;
1509
1510 Ok(())
1511}
1512
1513pub async fn tap(host: &Host, device_id: &str, x: u32, y: u32) -> eyre::Result<()> {
1521 let adb = Adb::locate(host).await?;
1522
1523 run_bounded_adb_command(
1524 host,
1525 &adb,
1526 [
1527 "-s",
1528 device_id,
1529 "shell",
1530 "input",
1531 "tap",
1532 &x.to_string(),
1533 &y.to_string(),
1534 ],
1535 "performing an Android tap",
1536 )
1537 .await?;
1538
1539 Ok(())
1540}
1541
1542pub async fn swipe(
1557 host: &Host,
1558 device_id: &str,
1559 from: (u32, u32),
1560 to: (u32, u32),
1561 duration_ms: Option<u32>,
1562) -> eyre::Result<()> {
1563 let adb = Adb::locate(host).await?;
1564
1565 let mut args = vec!["-s", device_id, "shell", "input", "swipe"];
1566
1567 let x1 = from.0.to_string();
1568 let y1 = from.1.to_string();
1569 let x2 = to.0.to_string();
1570 let y2 = to.1.to_string();
1571 let duration = duration_ms.map(|d| d.to_string());
1572
1573 args.push(&x1);
1574 args.push(&y1);
1575 args.push(&x2);
1576 args.push(&y2);
1577
1578 if let Some(ref d) = duration {
1579 args.push(d);
1580 }
1581
1582 run_bounded_adb_command(host, &adb, args, "performing an Android swipe").await?;
1583
1584 Ok(())
1585}
1586
1587pub async fn text(host: &Host, device_id: &str, input: &str) -> eyre::Result<()> {
1596 let adb = Adb::locate(host).await?;
1597
1598 let escaped = input
1600 .replace('\\', "\\\\")
1601 .replace(' ', "%s")
1602 .replace('"', "\\\"")
1603 .replace('\'', "\\'")
1604 .replace('&', "\\&")
1605 .replace('<', "\\<")
1606 .replace('>', "\\>")
1607 .replace('|', "\\|")
1608 .replace(';', "\\;")
1609 .replace('(', "\\(")
1610 .replace(')', "\\)");
1611
1612 run_bounded_adb_command(
1613 host,
1614 &adb,
1615 ["-s", device_id, "shell", "input", "text", &escaped],
1616 "entering text on an Android device",
1617 )
1618 .await?;
1619
1620 Ok(())
1621}
1622
1623pub async fn screenshot_bytes(host: &Host, device_id: &str) -> eyre::Result<Vec<u8>> {
1631 let adb = Adb::locate(host).await?;
1632
1633 let output = run_bounded_adb_output(
1634 host,
1635 &adb,
1636 ["-s", device_id, "exec-out", "screencap", "-p"],
1637 "capturing the Android device screen",
1638 )
1639 .await?;
1640
1641 if !output.status.success() {
1642 let stderr = String::from_utf8_lossy(&output.stderr);
1643 eyre::bail!("Failed to capture screenshot: {}", stderr.trim());
1644 }
1645
1646 Ok(output.stdout)
1647}
1648
1649pub async fn describe(host: &Host, device_id: &str) -> eyre::Result<String> {
1657 let adb = Adb::locate(host).await?;
1658
1659 let dump_path = "/sdcard/window_dump.xml";
1661 run_bounded_adb_command(
1662 host,
1663 &adb,
1664 ["-s", device_id, "shell", "uiautomator", "dump", dump_path],
1665 "dumping the Android accessibility hierarchy",
1666 )
1667 .await?;
1668
1669 let output = run_bounded_adb_output(
1671 host,
1672 &adb,
1673 ["-s", device_id, "shell", "cat", dump_path],
1674 "reading the Android accessibility hierarchy",
1675 )
1676 .await?;
1677
1678 if !output.status.success() {
1679 let stderr = String::from_utf8_lossy(&output.stderr);
1680 eyre::bail!("Failed to read UI dump: {}", stderr.trim());
1681 }
1682
1683 let xml = String::from_utf8_lossy(&output.stdout).to_string();
1684
1685 let json = xml_to_ui_json(&xml)?;
1687
1688 let _ = host
1690 .run(adb.path(), ["-s", device_id, "shell", "rm", dump_path])
1691 .await;
1692
1693 Ok(json)
1694}
1695
1696fn xml_to_ui_json(xml: &str) -> eyre::Result<String> {
1698 let mut elements = Vec::new();
1699
1700 for line in xml.lines() {
1702 if !line.contains("<node") {
1703 continue;
1704 }
1705
1706 let mut element = serde_json::Map::new();
1707
1708 if let Some(bounds_start) = line.find("bounds=\"[") {
1710 let bounds_str = &line[bounds_start + 8..];
1711 if let Some(bounds_end) = bounds_str.find('"') {
1712 let bounds = &bounds_str[..bounds_end];
1713 let parts: Vec<&str> = bounds
1715 .trim_matches(|c| c == '[' || c == ']')
1716 .split("][")
1717 .collect();
1718 if parts.len() == 2 {
1719 let lt: Vec<i32> = parts[0].split(',').filter_map(|s| s.parse().ok()).collect();
1720 let rb: Vec<i32> = parts[1].split(',').filter_map(|s| s.parse().ok()).collect();
1721 if lt.len() == 2 && rb.len() == 2 {
1722 let mut frame = serde_json::Map::new();
1723 frame.insert("x".to_string(), serde_json::Value::Number(lt[0].into()));
1724 frame.insert("y".to_string(), serde_json::Value::Number(lt[1].into()));
1725 frame.insert(
1726 "width".to_string(),
1727 serde_json::Value::Number((rb[0] - lt[0]).into()),
1728 );
1729 frame.insert(
1730 "height".to_string(),
1731 serde_json::Value::Number((rb[1] - lt[1]).into()),
1732 );
1733 element.insert("frame".to_string(), serde_json::Value::Object(frame));
1734 }
1735 }
1736 }
1737 }
1738
1739 for attr in ["text", "content-desc", "class", "resource-id"] {
1741 let search = format!("{attr}=\"");
1742 if let Some(start) = line.find(&search) {
1743 let value_start = start + search.len();
1744 let rest = &line[value_start..];
1745 if let Some(end) = rest.find('"') {
1746 let value = &rest[..end];
1747 if !value.is_empty() {
1748 let key = match attr {
1749 "content-desc" => "AXLabel",
1750 "class" => "type",
1751 "resource-id" => "AXUniqueId",
1752 "text" => "AXValue",
1753 _ => attr,
1754 };
1755 element.insert(
1756 key.to_string(),
1757 serde_json::Value::String(value.to_string()),
1758 );
1759 }
1760 }
1761 }
1762 }
1763
1764 if line.contains("clickable=\"true\"") {
1766 element.insert("clickable".to_string(), serde_json::Value::Bool(true));
1767 }
1768 if line.contains("enabled=\"true\"") {
1769 element.insert("enabled".to_string(), serde_json::Value::Bool(true));
1770 }
1771
1772 if !element.is_empty() {
1773 elements.push(serde_json::Value::Object(element));
1774 }
1775 }
1776
1777 serde_json::to_string(&elements).map_err(|e| eyre!("Failed to serialize UI elements: {e}"))
1778}
1779
1780#[cfg(test)]
1781mod tests {
1782 use std::ffi::OsString;
1783
1784 use super::{
1785 AndroidRuntimeEvent, adb_reports_device_ready, android_log_looks_like_crash,
1786 android_runtime_event_from_log_line, command_targets_avd, log_level_allows,
1787 log_mentions_pid,
1788 };
1789 use crate::device::LogLevel;
1790
1791 #[test]
1792 fn detects_pid_mentions_in_threadtime_lines() {
1793 let log = "12-10 23:04:40.190 28184 28184 F libc : Fatal signal 11 (SIGSEGV)\n";
1794 assert!(log_mentions_pid(log, 28184));
1795 assert!(!log_mentions_pid(log, 12345));
1796 }
1797
1798 #[test]
1799 fn avoids_false_positive_from_unrelated_fatal_signal_in_global_dump() {
1800 let unrelated = "12-10 23:04:40.190 999 999 F libc : Fatal signal 11 (SIGSEGV)\n";
1801 assert!(!android_log_looks_like_crash(
1802 unrelated,
1803 "com.example.app",
1804 28184
1805 ));
1806 }
1807
1808 #[test]
1809 fn detects_native_crash_when_pid_is_mentioned() {
1810 let log = "I DEBUG : Fatal signal 11 (SIGSEGV), code 1, fault addr 0x0 in tid 1 (main) pid: 28184\n";
1811 assert!(android_log_looks_like_crash(log, "com.example.app", 28184));
1812 }
1813
1814 #[test]
1815 fn detects_java_crash_for_app() {
1816 let log = "E AndroidRuntime: FATAL EXCEPTION: main\nE AndroidRuntime: Process: com.example.app, PID: 28184\n";
1817 assert!(android_log_looks_like_crash(log, "com.example.app", 28184));
1818 }
1819
1820 #[test]
1821 fn does_not_treat_rust_backtrace_symbols_as_native_crash() {
1822 let line = "09-17 02:01:52.199 6229 6229 W WaterUI : waterui::runtime_guard: \
1826 Main-thread task poll reached frame-budget warning threshold\
1827 backtrace= 0: <std::backtrace::Backtrace>::create";
1828 assert!(android_runtime_event_from_log_line(line).is_none());
1829
1830 let tombstone = "09-17 02:01:52.199 6229 6229 F DEBUG : backtrace:";
1831 assert!(matches!(
1832 android_runtime_event_from_log_line(tombstone),
1833 Some(AndroidRuntimeEvent::NativeCrash(_))
1834 ));
1835 }
1836
1837 #[test]
1838 fn detects_activity_completion_marker() {
1839 let event = android_runtime_event_from_log_line(
1840 "07-26 20:00:00.000 28184 28184 I WaterUI.MainActivity: WATERUI_ACTIVITY_FINISHED",
1841 );
1842 assert!(matches!(event, Some(AndroidRuntimeEvent::ActivityFinished)));
1843 }
1844
1845 #[test]
1846 fn filters_internal_lifecycle_logs_from_stricter_user_log_levels() {
1847 assert!(!log_level_allows(LogLevel::Error, tracing::Level::INFO));
1848 assert!(!log_level_allows(LogLevel::Warn, tracing::Level::INFO));
1849 assert!(log_level_allows(LogLevel::Info, tracing::Level::INFO));
1850 assert!(log_level_allows(LogLevel::Debug, tracing::Level::DEBUG));
1851 assert!(log_level_allows(LogLevel::Verbose, tracing::Level::TRACE));
1852 }
1853
1854 #[test]
1855 fn matches_emulator_process_to_exact_avd_argument() {
1856 let command = [
1857 OsString::from("qemu-system-aarch64"),
1858 OsString::from("-netdelay"),
1859 OsString::from("none"),
1860 OsString::from("-avd"),
1861 OsString::from("Pixel_9"),
1862 ];
1863
1864 assert!(command_targets_avd(&command, "Pixel_9".as_ref()));
1865 assert!(!command_targets_avd(&command, "Pixel_9_Pro".as_ref()));
1866 }
1867
1868 #[test]
1869 fn does_not_match_unrelated_avd_text() {
1870 let command = [
1871 OsString::from("emulator-helper"),
1872 OsString::from("--log"),
1873 OsString::from("starting Pixel_9"),
1874 ];
1875
1876 assert!(!command_targets_avd(&command, "Pixel_9".as_ref()));
1877 }
1878
1879 #[test]
1880 fn parses_ready_android_device_state() {
1881 let output = "List of devices attached\nemulator-5554 device product:sdk_phone64_arm64 transport_id:1\n";
1882
1883 assert!(adb_reports_device_ready(output, "emulator-5554"));
1884 assert!(!adb_reports_device_ready(output, "emulator-5556"));
1885 }
1886
1887 #[test]
1888 fn rejects_offline_android_device_state() {
1889 let output = "List of devices attached\nemulator-5554 offline transport_id:1\n";
1890
1891 assert!(!adb_reports_device_ready(output, "emulator-5554"));
1892 }
1893}