Skip to main content

waterui_cli/android/
device.rs

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/// Panic information extracted from logcat.
26#[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/// An `adb` device command could not be spawned, timed out, or exited unsuccessfully.
43#[derive(Debug, thiserror::Error)]
44enum AdbCommandError {
45    /// The `adb` process could not be spawned.
46    #[error(transparent)]
47    Spawn(#[from] CommandError),
48    /// The command did not finish within the bound.
49    #[error("{operation} timed out after {seconds} seconds")]
50    Timeout {
51        /// Human-readable name of the operation.
52        operation: String,
53        /// The bound that elapsed.
54        seconds: u64,
55    },
56    /// The command exited with a non-zero status.
57    #[error("{operation} failed with status {status}{details}")]
58    Failed {
59        /// Human-readable name of the operation.
60        operation: String,
61        /// The process exit status.
62        status: ExitStatus,
63        /// Formatted stdout/stderr tail.
64        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/// Represents an Android device (physical or emulator).
134#[derive(Debug)]
135pub struct AndroidDevice {
136    identifier: String,
137    /// Primary ABI of the device (e.g., "arm64-v8a", "`x86_64`")
138    abi: AndroidAbi,
139}
140
141impl AndroidDevice {
142    /// Create a new Android device with the given identifier and ABI.
143    #[must_use]
144    pub const fn new(identifier: String, abi: AndroidAbi) -> Self {
145        Self { identifier, abi }
146    }
147
148    /// Get the device identifier.
149    #[must_use]
150    pub fn identifier(&self) -> &str {
151        &self.identifier
152    }
153
154    /// Get the device's primary ABI.
155    #[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    /// The connected devices `adb` reports, with the ABI of each.
190    ///
191    /// # Errors
192    /// Returns an error when `adb devices` fails or a device's ABI cannot be
193    /// read.
194    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                // Get device ABI
208                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
229/// Provides the Android ABI required to build/package for a target.
230pub trait AndroidAbiProvider {
231    /// Return the device ABI used for building and packaging.
232    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
247/// Shared implementation for running an app on any Android device.
248///
249/// This handles:
250/// - Passing environment variables as intent extras
251/// - Uninstalling previous version (to avoid storage issues)
252/// - Installing the APK
253/// - Launching the app
254/// - Monitoring process state
255/// - Streaming logs
256async 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    // A dev-server URL the app is handed must be reachable: `adb reverse`
271    // maps the target's loopback port onto the host's, for emulators and
272    // USB-connected devices alike.
273    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    // The preview support app listens on the device's loopback; `adb forward`
281    // maps each candidate host port onto it so the CLI can reach the server.
282    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    // Wait for the process to start and get its PID
295    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
359/// The environment the app process starts with, delivered as `waterui.env.*`
360/// intent extras; the generated `MainActivity` applies each of them with
361/// `Os.setenv` before loading the native library.
362fn 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
387/// `adb -s <device> forward tcp:<port> tcp:<port>` maps a host loopback port
388/// onto the device's loopback, where a preview support app listens.
389async fn forward_device_port(
390    host: &Host,
391    adb: &Adb,
392    device_id: &str,
393    port: u16,
394) -> Result<(), FailToRun> {
395    // A previous session may have left a mapping behind; `adb forward` refuses
396    // to rebind a taken host port, so clear it first. Removing a mapping that
397    // does not exist is a reported error, which is expected and ignored.
398    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
438/// Removes the `adb forward` mappings a run created when it is dropped.
439struct 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
475/// `adb -s <device> reverse tcp:<port> tcp:<port>` before the app launches,
476/// so the dev-server URL it receives resolves on the device as printed.
477async 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
637/// Wait for an app to start and return its PID.
638async 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    // App likely crashed on startup - fetch logcat for crash info
660    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
693/// Query the AVD name for a running emulator device via `adb emu avd name`.
694///
695/// # Errors
696/// Returns an error if the device isn't an emulator or adb doesn't return a name.
697pub 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
720/// Resolve the AVD name for a running emulator device.
721///
722/// # Errors
723/// Returns an error if adb isn't available or the emulator doesn't return a name.
724pub 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                // Some emulator builds may not respond until fully booted.
747                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
845/// Monitor an Android process and send events when it crashes or exits.
846async 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    // Check process status periodically
855    loop {
856        smol::Timer::after(std::time::Duration::from_secs(1)).await;
857
858        // Check if process is still running using pidof
859        // Note: We use pidof instead of kill -0 because kill -0 returns "Operation not permitted"
860        // when the shell user doesn't have permission to send signals to the app process
861        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        // Check if the process with the same PID is still running.
884        let still_running = pids.contains(&pid);
885
886        if !still_running {
887            // Try to fetch logs for this PID (best signal for distinguishing crash vs normal exit).
888            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    // Ensure log lines mention this app before applying AndroidRuntime heuristics.
998    if !log.contains(bundle_id) {
999        return false;
1000    }
1001
1002    // Heuristic: treat AndroidRuntime errors for this process as crash.
1003    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
1019/// Match the `backtrace:` header a libc tombstone dumps under the `DEBUG` tag
1020/// (`F DEBUG   : backtrace:`) without matching Rust symbol paths such as
1021/// `std::backtrace::Backtrace`, which log lines legitimately contain.
1022fn 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
1028/// Start log streaming from an Android process using logcat.
1029///
1030/// Always streams at minimum info level to capture lifecycle completion and panics.
1031/// Returns a receiver that fires when the Activity finishes or the runtime crashes.
1032fn 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    // Bounded channel with capacity 1 acts as a oneshot for the first terminal event.
1044    let (runtime_event_tx, runtime_event_rx) = smol::channel::bounded::<AndroidRuntimeEvent>(1);
1045
1046    // Lifecycle completion is logged at info, so the internal stream must include info even
1047    // when terminal log display is disabled or configured for a stricter level.
1048    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    // Build logcat command with PID filter and minimum priority
1055    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        // Parse logcat output and send as DeviceEvent::Log
1080        // Logcat format: "MM-DD HH:MM:SS.mmm  PID  TID LEVEL TAG: message"
1081        while let Some(result) = lines.next().await {
1082            let Ok(line) = result else { break };
1083
1084            // Extract the first terminal event directly from the live process-filtered stream.
1085            if let Some(event) = android_runtime_event_from_log_line(&line) {
1086                let _ = runtime_event_tx.try_send(event);
1087            }
1088
1089            // Only send log events to display if user requested logs
1090            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        // Clean up child process
1107        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
1142/// Extract panic information from a log line containing panic.payload and panic.location fields.
1143fn extract_panic_info_from_log(line: &str) -> Option<PanicInfo> {
1144    let mut payload = None;
1145    let mut location = None;
1146
1147    // Extract panic.payload="..."
1148    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    // Extract panic.location="..."
1156    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
1169/// Parsed logcat line with level, tag, and message.
1170struct LogcatParsed {
1171    level: tracing::Level,
1172    tag: String,
1173    message: String,
1174}
1175
1176/// Parse a logcat line into level, tag, and message.
1177/// Logcat threadtime format: "MM-DD HH:MM:SS.mmm  PID  TID LEVEL TAG: message"
1178fn parse_logcat_line(line: &str) -> (tracing::Level, String) {
1179    // Try to parse the structured format
1180    if let Some(parsed) = try_parse_logcat(line) {
1181        let formatted = format!("[{}] {}", parsed.tag, parsed.message);
1182        return (parsed.level, formatted);
1183    }
1184
1185    // Fallback: return raw line with default level
1186    (tracing::Level::INFO, line.to_string())
1187}
1188
1189/// Try to parse a logcat line. Returns None if parsing fails.
1190fn try_parse_logcat(line: &str) -> Option<LogcatParsed> {
1191    // Logcat threadtime format: "MM-DD HH:MM:SS.mmm  PID  TID LEVEL TAG: message"
1192    // Example: "12-10 23:04:40.190 28184 28184 D WaterUI : Touch..."
1193
1194    // Split by whitespace, but we need to be careful about the message part
1195    let parts: Vec<&str> = line.splitn(7, char::is_whitespace).collect();
1196
1197    // We need at least: date, time, pid, tid, level, tag, message
1198    if parts.len() < 6 {
1199        return None;
1200    }
1201
1202    // Find the level character (should be single char: V, D, I, W, E, F)
1203    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    // The rest after level is "TAG: message" or "TAG     : message"
1228    // Find the position of the level character in the original line (after timestamp)
1229    // Skip past timestamp "MM-DD HH:MM:SS.mmm" which is about 18 chars
1230    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    // Split by ": " to get tag and message
1239    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/// Android emulator (AVD) that needs to be launched.
1260///
1261/// Unlike `AndroidDevice` which represents an already-connected device,
1262/// `AndroidEmulator` represents an AVD that will be launched when `launch()` is called.
1263#[derive(Debug)]
1264pub struct AndroidEmulator {
1265    /// AVD name.
1266    avd_name: String,
1267    expected_abi: AndroidAbi,
1268    device: OnceLock<AndroidDevice>,
1269}
1270
1271impl AndroidEmulator {
1272    /// Open an Android emulator definition by AVD name (reads config to determine ABI).
1273    ///
1274    /// # Errors
1275    /// Returns an error if the AVD config is missing or malformed.
1276    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    /// Get the AVD name.
1286    #[must_use]
1287    pub fn avd_name(&self) -> &str {
1288        &self.avd_name
1289    }
1290
1291    #[must_use]
1292    /// ABI expected for this AVD (from its config).
1293    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            // Start the emulator process (don't wait for it here, we'll poll for readiness).
1316            // Use std::process::Command so we can isolate process-group behavior.
1317            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                // Move emulator into its own process group so Ctrl+C in `water run`
1329                // only stops the CLI/app and doesn't terminate the emulator process.
1330                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        // List available AVDs using avdmanager or emulator -list-avds
1433        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
1480/// Capture a screenshot from an Android device.
1481///
1482/// Uses `adb exec-out screencap -p` to capture the screen and writes
1483/// the PNG data directly to the output file.
1484///
1485/// # Errors
1486///
1487/// Returns an error if the screenshot command fails, the device is not
1488/// available, or the output file cannot be written.
1489pub 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    // Write the PNG data to the output file
1506    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
1513/// Perform a tap gesture on an Android device at the specified coordinates.
1514///
1515/// Uses `adb shell input tap <x> <y>` to simulate a touch event.
1516///
1517/// # Errors
1518///
1519/// Returns an error if the tap command fails or the device is not available.
1520pub 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
1542/// Perform a swipe gesture on an Android device.
1543///
1544/// Uses `adb shell input swipe <x1> <y1> <x2> <y2> [duration_ms]` to simulate a swipe.
1545///
1546/// # Arguments
1547///
1548/// * `device_id` - The Android device identifier
1549/// * `from` - Starting coordinates (x, y)
1550/// * `to` - Ending coordinates (x, y)
1551/// * `duration_ms` - Optional duration in milliseconds (default ~300ms if not specified)
1552///
1553/// # Errors
1554///
1555/// Returns an error if the swipe command fails or the device is not available.
1556pub 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
1587/// Input text on an Android device.
1588///
1589/// Uses `adb shell input text "<string>"` to type text.
1590/// Note: Special characters may need escaping.
1591///
1592/// # Errors
1593///
1594/// Returns an error if the text input command fails or the device is not available.
1595pub async fn text(host: &Host, device_id: &str, input: &str) -> eyre::Result<()> {
1596    let adb = Adb::locate(host).await?;
1597
1598    // Escape special characters for shell
1599    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
1623/// Capture a screenshot from an Android device and return the raw PNG bytes.
1624///
1625/// This is used for the diff workflow where we need in-memory screenshots.
1626///
1627/// # Errors
1628///
1629/// Returns an error if the screenshot command fails or the device is not available.
1630pub 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
1649/// Describe UI elements on the screen.
1650///
1651/// Uses `uiautomator dump` to get UI hierarchy as XML, then converts to JSON.
1652///
1653/// # Errors
1654///
1655/// Returns an error if adb is not available or the command fails.
1656pub async fn describe(host: &Host, device_id: &str) -> eyre::Result<String> {
1657    let adb = Adb::locate(host).await?;
1658
1659    // Dump UI hierarchy to a temp file on device
1660    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    // Read the dump file
1670    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    // Convert XML to simplified JSON format
1686    let json = xml_to_ui_json(&xml)?;
1687
1688    // Clean up
1689    let _ = host
1690        .run(adb.path(), ["-s", device_id, "shell", "rm", dump_path])
1691        .await;
1692
1693    Ok(json)
1694}
1695
1696/// Convert Android UI XML dump to a JSON format similar to iOS IDB output.
1697fn xml_to_ui_json(xml: &str) -> eyre::Result<String> {
1698    let mut elements = Vec::new();
1699
1700    // Simple XML parsing - find all <node> elements
1701    for line in xml.lines() {
1702        if !line.contains("<node") {
1703            continue;
1704        }
1705
1706        let mut element = serde_json::Map::new();
1707
1708        // Extract bounds attribute: bounds="[left,top][right,bottom]"
1709        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                // Parse [left,top][right,bottom]
1714                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        // Extract common attributes
1740        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        // Extract clickable/enabled attributes
1765        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        // A frame-budget warning prints a Rust backtrace whose symbol paths
1823        // contain `backtrace::`; that substring must not satisfy the tombstone
1824        // `backtrace:` marker.
1825        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}