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    install_android_artifact(host, &adb, device_id, artifact.path()).await?;
281    launch_android_app(
282        host,
283        &adb,
284        build_android_start_args(device_id, &artifact, &env_vars),
285    )
286    .await?;
287
288    // Wait for the process to start and get its PID
289    let pid = wait_for_app_pid(host, &adb, device_id, artifact.bundle_id()).await?;
290
291    let host_for_kill = host.clone();
292    let adb_for_kill = adb.clone();
293    let device_id_for_kill = device_id.to_string();
294    let bundle_id_for_kill = artifact.bundle_id().to_string();
295
296    let (running, sender) = Running::new(move || {
297        spawn_android_force_stop(
298            &host_for_kill,
299            adb_for_kill,
300            device_id_for_kill,
301            bundle_id_for_kill,
302        );
303    });
304
305    spawn_android_runtime_tasks(AndroidRuntimeTaskContext {
306        host,
307        adb: &adb,
308        device_id,
309        bundle_id: artifact.bundle_id(),
310        pid,
311        log_level: options.log_level(),
312        sender,
313    });
314
315    Ok(running)
316}
317
318async fn install_android_artifact(
319    host: &Host,
320    adb: &Adb,
321    device_id: &str,
322    artifact_path: &Path,
323) -> Result<(), FailToRun> {
324    let install_output = host
325        .command(adb.path())
326        .args(["-s", device_id, "install", "-r"])
327        .arg(artifact_path)
328        .stdout(Stdio::piped())
329        .stderr(Stdio::piped())
330        .output()
331        .await
332        .map_err(|error| FailToRun::Install(eyre!("Failed to install APK: {error}")))?;
333
334    if install_output.status.success() {
335        return Ok(());
336    }
337
338    Err(FailToRun::Install(eyre!(
339        "Failed to install APK:\n{}\n{}",
340        String::from_utf8_lossy(&install_output.stdout).trim(),
341        String::from_utf8_lossy(&install_output.stderr).trim(),
342    )))
343}
344
345/// The environment the app process starts with, delivered as `waterui.env.*`
346/// intent extras; the generated `MainActivity` applies each of them with
347/// `Os.setenv` before loading the native library.
348fn build_android_start_args(
349    device_id: &str,
350    artifact: &Artifact,
351    env_vars: &[(String, String)],
352) -> Vec<String> {
353    let mut start_args = vec![
354        "-s".to_string(),
355        device_id.to_string(),
356        "shell".to_string(),
357        "am".to_string(),
358        "start".to_string(),
359        "-S".to_string(),
360        "-n".to_string(),
361        format!("{}/.MainActivity", artifact.bundle_id()),
362    ];
363
364    for (key, value) in env_vars {
365        start_args.push("--es".to_string());
366        start_args.push(format!("waterui.env.{key}"));
367        start_args.push(value.clone());
368    }
369
370    start_args
371}
372
373/// `adb -s <device> reverse tcp:<port> tcp:<port>` before the app launches,
374/// so the dev-server URL it receives resolves on the device as printed.
375async fn reverse_dev_server_port(
376    host: &Host,
377    adb: &Adb,
378    device_id: &str,
379    port: u16,
380) -> Result<(), FailToRun> {
381    let output = host
382        .command(adb.path())
383        .args(crate::web::adb_reverse_args(device_id, port))
384        .stdout(Stdio::piped())
385        .stderr(Stdio::piped())
386        .output()
387        .await
388        .map_err(|error| FailToRun::Launch(eyre!("Failed to run `adb reverse`: {error}")))?;
389
390    if output.status.success() {
391        return Ok(());
392    }
393
394    Err(FailToRun::Launch(eyre!(
395        "`adb reverse tcp:{port} tcp:{port}` failed:\n{}\n{}",
396        String::from_utf8_lossy(&output.stdout).trim(),
397        String::from_utf8_lossy(&output.stderr).trim(),
398    )))
399}
400
401async fn launch_android_app(
402    host: &Host,
403    adb: &Adb,
404    start_args: Vec<String>,
405) -> Result<(), FailToRun> {
406    let output = host
407        .command(adb.path())
408        .args(&start_args)
409        .stdout(Stdio::piped())
410        .stderr(Stdio::piped())
411        .output()
412        .await
413        .map_err(|error| FailToRun::Launch(eyre!("Failed to launch app: {error}")))?;
414
415    if output.status.success() {
416        return Ok(());
417    }
418
419    Err(FailToRun::Launch(eyre!(
420        "Failed to launch app:\n{}\n{}",
421        String::from_utf8_lossy(&output.stdout).trim(),
422        String::from_utf8_lossy(&output.stderr).trim(),
423    )))
424}
425
426fn spawn_android_force_stop(host: &Host, adb: Adb, device_id: String, bundle_id: String) {
427    let host = host.clone();
428    let spawn_result = std::thread::Builder::new()
429        .name("waterui-android-force-stop".to_string())
430        .spawn(move || {
431            let result = host
432                .std_command(adb.path())
433                .args(["-s", &device_id, "shell", "am", "force-stop", &bundle_id])
434                .output();
435
436            match result {
437                Ok(output) => {
438                    tracing::debug!(
439                        "Force-stop command executed: status={}, stdout={}, stderr={}",
440                        output.status,
441                        String::from_utf8_lossy(&output.stdout),
442                        String::from_utf8_lossy(&output.stderr)
443                    );
444                }
445                Err(error) => {
446                    error!("Failed to stop app {}: {}", bundle_id, error);
447                }
448            }
449        });
450
451    if let Err(error) = spawn_result {
452        error!("Failed to spawn force-stop worker thread: {error}");
453    }
454}
455
456struct AndroidRuntimeTaskContext<'a> {
457    host: &'a Host,
458    adb: &'a Adb,
459    device_id: &'a str,
460    bundle_id: &'a str,
461    pid: u32,
462    log_level: Option<LogLevel>,
463    sender: Sender<DeviceEvent>,
464}
465
466fn spawn_android_runtime_tasks(context: AndroidRuntimeTaskContext<'_>) {
467    let AndroidRuntimeTaskContext {
468        host,
469        adb,
470        device_id,
471        bundle_id,
472        pid,
473        log_level,
474        sender,
475    } = context;
476    let sender_for_monitor = sender.clone();
477    let sender_for_runtime_event = sender.clone();
478    let sender_for_logs = sender;
479    let adb_for_monitor = adb.clone();
480    let host_for_monitor = host.clone();
481    let device_id_for_monitor = device_id.to_string();
482    let bundle_id_for_monitor = bundle_id.to_string();
483
484    smol::spawn(async move {
485        monitor_android_process(
486            host_for_monitor,
487            adb_for_monitor,
488            &device_id_for_monitor,
489            &bundle_id_for_monitor,
490            pid,
491            sender_for_monitor,
492        )
493        .await;
494    })
495    .detach();
496
497    let runtime_event_rx =
498        start_android_log_stream(host, adb, device_id, pid, log_level, sender_for_logs);
499    spawn(async move {
500        if let Ok(event) = runtime_event_rx.recv().await {
501            match event {
502                AndroidRuntimeEvent::Panic(info) => {
503                    let _ = sender_for_runtime_event
504                        .send(DeviceEvent::Crashed(format_android_panic(&info)))
505                        .await;
506                }
507                AndroidRuntimeEvent::NativeCrash(log) => {
508                    let _ = sender_for_runtime_event
509                        .send(DeviceEvent::Crashed(format!(
510                            "Android process crashed.\n\n=== Crash Log ===\n{log}"
511                        )))
512                        .await;
513                }
514                AndroidRuntimeEvent::ActivityFinished => {
515                    let _ = sender_for_runtime_event
516                        .send(DeviceEvent::Exited(ApplicationExit::user_closed()))
517                        .await;
518                }
519            }
520        }
521    })
522    .detach();
523}
524
525fn format_android_panic(info: &PanicInfo) -> String {
526    let mut msg = format!("Panic: {}", info.payload);
527    if let Some(location) = &info.location {
528        msg.push('\n');
529        msg.push_str("  at ");
530        msg.push_str(location);
531    }
532    msg
533}
534
535/// Wait for an app to start and return its PID.
536async fn wait_for_app_pid(
537    host: &Host,
538    adb: &Adb,
539    device_id: &str,
540    bundle_id: &str,
541) -> Result<u32, FailToRun> {
542    for _ in 0..10 {
543        smol::Timer::after(std::time::Duration::from_millis(200)).await;
544        if let Ok(output) = run_bounded_adb_command(
545            host,
546            adb,
547            ["-s", device_id, "shell", "pidof", bundle_id],
548            "querying the launched Android process",
549        )
550        .await
551            && let Some(pid) = parse_whitespace_separated_u32s(&output).into_iter().next()
552        {
553            return Ok(pid);
554        }
555    }
556
557    // App likely crashed on startup - fetch logcat for crash info
558    let crash_info = match run_bounded_adb_command(
559        host,
560        adb,
561        [
562            "-s",
563            device_id,
564            "logcat",
565            "-d",
566            "-t",
567            "100",
568            "-s",
569            "AndroidRuntime:E",
570            "DEBUG:*",
571            "WaterUI:*",
572        ],
573        "collecting Android startup crash logs",
574    )
575    .await
576    {
577        Ok(output) => output,
578        Err(err) => format!("(failed to collect logcat crash info: {err})"),
579    };
580
581    let mut error_msg = format!("App {bundle_id} crashed on startup (process not found).\n\n");
582
583    if !crash_info.trim().is_empty() {
584        error_msg.push_str("=== Crash Log ===\n");
585        error_msg.push_str(&crash_info);
586    }
587
588    Err(FailToRun::Launch(eyre!("{}", error_msg)))
589}
590
591/// Query the AVD name for a running emulator device via `adb emu avd name`.
592///
593/// # Errors
594/// Returns an error if the device isn't an emulator or adb doesn't return a name.
595pub async fn emulator_avd_name_with_adb(
596    host: &Host,
597    adb: &Adb,
598    emulator_id: &str,
599) -> eyre::Result<String> {
600    if !emulator_id.starts_with("emulator-") {
601        eyre::bail!("Not an Android emulator identifier: {emulator_id}");
602    }
603
604    let output = run_bounded_adb_command(
605        host,
606        adb,
607        ["-s", emulator_id, "emu", "avd", "name"],
608        "querying the Android emulator name",
609    )
610    .await?;
611    let name = output.lines().next().unwrap_or_default().trim();
612    if name.is_empty() {
613        eyre::bail!("Failed to query AVD name for {emulator_id}: empty response");
614    }
615    Ok(name.to_string())
616}
617
618/// Resolve the AVD name for a running emulator device.
619///
620/// # Errors
621/// Returns an error if adb isn't available or the emulator doesn't return a name.
622pub async fn emulator_avd_name(host: &Host, emulator_id: &str) -> eyre::Result<String> {
623    let adb = Adb::locate(host).await?;
624    emulator_avd_name_with_adb(host, &adb, emulator_id).await
625}
626
627async fn try_find_running_emulator_for_avd(
628    host: &Host,
629    adb: &Adb,
630    avd_name: &str,
631) -> eyre::Result<Option<AndroidDevice>> {
632    let devices = AndroidDevice::scan_with_adb(host, adb).await?;
633
634    for device in devices {
635        let id = device.identifier();
636        if !id.starts_with("emulator-") {
637            continue;
638        }
639
640        match emulator_avd_name_with_adb(host, adb, id).await {
641            Ok(name) if name == avd_name => return Ok(Some(device)),
642            Ok(_) => {}
643            Err(e) => {
644                // Some emulator builds may not respond until fully booted.
645                debug!("Failed to query AVD name for {id}: {e}");
646            }
647        }
648    }
649
650    Ok(None)
651}
652
653async fn adb_emulator_states(host: &Host, adb: &Adb) -> eyre::Result<String> {
654    let output = run_bounded_adb_command(
655        host,
656        adb,
657        ["devices", "-l"],
658        "querying Android emulator state",
659    )
660    .await?;
661    let states: Vec<String> = output
662        .lines()
663        .skip(1)
664        .map(str::trim)
665        .filter(|line| line.starts_with("emulator-"))
666        .map(ToOwned::to_owned)
667        .collect();
668
669    if states.is_empty() {
670        return Ok(String::new());
671    }
672
673    Ok(states.join("; "))
674}
675
676async fn adb_emulator_boot_completed(host: &Host, adb: &Adb, emulator_id: &str) -> bool {
677    run_bounded_adb_command(
678        host,
679        adb,
680        ["-s", emulator_id, "shell", "getprop", "sys.boot_completed"],
681        "querying Android emulator boot completion",
682    )
683    .await
684    .is_ok_and(|value| value.trim() == "1")
685}
686
687fn adb_reports_device_ready(output: &str, device_id: &str) -> bool {
688    output.lines().skip(1).any(|line| {
689        let mut fields = line.split_whitespace();
690        fields.next() == Some(device_id) && fields.next() == Some("device")
691    })
692}
693
694async fn adb_device_is_ready(host: &Host, adb: &Adb, device_id: &str) -> eyre::Result<bool> {
695    let output = run_bounded_adb_command(
696        host,
697        adb,
698        ["devices", "-l"],
699        "querying Android device readiness",
700    )
701    .await?;
702    Ok(adb_reports_device_ready(&output, device_id))
703}
704
705fn command_targets_avd(command: &[OsString], avd_name: &OsStr) -> bool {
706    command
707        .windows(2)
708        .any(|arguments| arguments[0] == "-avd" && arguments[1] == avd_name)
709}
710
711async fn avd_process_is_running(avd_name: &str) -> bool {
712    let avd_name = OsString::from(avd_name);
713    smol::unblock(move || {
714        let mut processes = System::new();
715        processes.refresh_processes_specifics(
716            ProcessesToUpdate::All,
717            true,
718            ProcessRefreshKind::nothing().with_cmd(UpdateKind::Always),
719        );
720        processes
721            .processes()
722            .values()
723            .any(|process| command_targets_avd(process.cmd(), &avd_name))
724    })
725    .await
726}
727
728async fn adb_package_manager_ready(host: &Host, adb: &Adb, emulator_id: &str) -> bool {
729    run_bounded_adb_command(
730        host,
731        adb,
732        ["-s", emulator_id, "shell", "pm", "path", "android"],
733        "querying Android package manager readiness",
734    )
735    .await
736    .is_ok_and(|output| {
737        output
738            .lines()
739            .any(|line| line.trim().starts_with("package:"))
740    })
741}
742
743/// Monitor an Android process and send events when it crashes or exits.
744async fn monitor_android_process(
745    host: Host,
746    adb: Adb,
747    device_id: &str,
748    bundle_id: &str,
749    pid: u32,
750    sender: smol::channel::Sender<DeviceEvent>,
751) {
752    // Check process status periodically
753    loop {
754        smol::Timer::after(std::time::Duration::from_secs(1)).await;
755
756        // Check if process is still running using pidof
757        // Note: We use pidof instead of kill -0 because kill -0 returns "Operation not permitted"
758        // when the shell user doesn't have permission to send signals to the app process
759        let pids = match query_android_process_pids(&host, &adb, device_id, bundle_id).await {
760            Ok(pids) => pids,
761            Err(err) => {
762                if adb_device_is_ready(&host, &adb, device_id)
763                    .await
764                    .is_ok_and(|ready| !ready)
765                {
766                    debug!(
767                        "Android device {device_id} disconnected while monitoring {bundle_id}: {err}"
768                    );
769                    let _ = sender
770                        .send(DeviceEvent::Exited(ApplicationExit::user_closed()))
771                        .await;
772                    break;
773                }
774                debug!(
775                    "Failed to query process state via pidof for {bundle_id} on {device_id}: {err}"
776                );
777                continue;
778            }
779        };
780
781        // Check if the process with the same PID is still running.
782        let still_running = pids.contains(&pid);
783
784        if !still_running {
785            // Try to fetch logs for this PID (best signal for distinguishing crash vs normal exit).
786            let pid_arg = format!("--pid={pid}");
787            let pid_log_args = vec![
788                "-s".to_string(),
789                device_id.to_string(),
790                "logcat".to_string(),
791                "-v".to_string(),
792                "threadtime".to_string(),
793                "-d".to_string(),
794                "-t".to_string(),
795                "200".to_string(),
796                pid_arg,
797                "*:V".to_string(),
798            ];
799            let pid_log = run_bounded_adb_output(
800                &host,
801                &adb,
802                pid_log_args
803                    .iter()
804                    .map(|s| std::ffi::OsStr::new(s.as_str())),
805                "collecting Android process exit logs",
806            )
807            .await
808            .map_or_else(
809                |err| {
810                    debug!("Failed to fetch PID-filtered logcat: {err}");
811                    String::new()
812                },
813                |output| {
814                    if output.status.success() {
815                        String::from_utf8_lossy(&output.stdout).to_string()
816                    } else {
817                        debug!("PID-filtered logcat exited with status {}", output.status);
818                        String::new()
819                    }
820                },
821            );
822
823            if android_log_looks_like_crash(&pid_log, bundle_id, pid) {
824                let crash_log = pid_log;
825
826                let error_msg = if crash_log.trim().is_empty() {
827                    format!("Process {bundle_id} crashed.")
828                } else {
829                    format!("Process {bundle_id} crashed.\n\n=== Crash Log ===\n{crash_log}")
830                };
831
832                let _ = sender.send(DeviceEvent::Crashed(error_msg)).await;
833            } else {
834                let _ = sender
835                    .send(DeviceEvent::Exited(ApplicationExit::user_closed()))
836                    .await;
837            }
838            break;
839        }
840    }
841}
842
843async fn query_android_process_pids(
844    host: &Host,
845    adb: &Adb,
846    device_id: &str,
847    bundle_id: &str,
848) -> eyre::Result<Vec<u32>> {
849    let output = run_bounded_adb_output(
850        host,
851        adb,
852        ["-s", device_id, "shell", "pidof", bundle_id].map(OsStr::new),
853        "querying Android process state",
854    )
855    .await?;
856    let stdout = String::from_utf8_lossy(&output.stdout);
857    if output.status.success() || stdout.trim().is_empty() {
858        return Ok(parse_whitespace_separated_u32s(&stdout));
859    }
860
861    let stderr = String::from_utf8_lossy(&output.stderr);
862    eyre::bail!(
863        "pidof failed with status {}: {}",
864        output.status,
865        stderr.trim()
866    );
867}
868
869fn log_mentions_pid(log: &str, pid: u32) -> bool {
870    let pid_str = pid.to_string();
871    let pid_lower = format!("pid: {pid}");
872    let pid_upper = format!("PID: {pid}");
873
874    log.lines().any(|line| {
875        line.split_whitespace().any(|part| part == pid_str)
876            || line.contains(&pid_lower)
877            || line.contains(&pid_upper)
878    })
879}
880
881fn android_log_looks_like_crash(log: &str, bundle_id: &str, pid: u32) -> bool {
882    if log.trim().is_empty() {
883        return false;
884    }
885
886    let relevant = log.contains(bundle_id) || log_mentions_pid(log, pid);
887    if !relevant {
888        return false;
889    }
890
891    if android_log_line_looks_like_crash(log) {
892        return true;
893    }
894
895    // Ensure log lines mention this app before applying AndroidRuntime heuristics.
896    if !log.contains(bundle_id) {
897        return false;
898    }
899
900    // Heuristic: treat AndroidRuntime errors for this process as crash.
901    log.contains("AndroidRuntime")
902        && (log.contains("E AndroidRuntime") || log.contains("Exception"))
903}
904
905fn android_log_line_looks_like_crash(line: &str) -> bool {
906    line.contains("FATAL EXCEPTION")
907        || line.contains("Fatal signal")
908        || line.contains("SIGSEGV")
909        || line.contains("SIGABRT")
910        || line.contains("SIGBUS")
911        || line.contains("SIGILL")
912        || line.contains("SIGFPE")
913        || line.contains("Abort message:")
914        || line.contains("backtrace:")
915}
916
917/// Start log streaming from an Android process using logcat.
918///
919/// Always streams at minimum info level to capture lifecycle completion and panics.
920/// Returns a receiver that fires when the Activity finishes or the runtime crashes.
921fn start_android_log_stream(
922    host: &Host,
923    adb: &Adb,
924    device_id: &str,
925    pid: u32,
926    log_level: Option<LogLevel>,
927    sender: Sender<DeviceEvent>,
928) -> Receiver<AndroidRuntimeEvent> {
929    use futures_util::StreamExt;
930    use futures_util::io::{AsyncBufReadExt, BufReader};
931
932    // Bounded channel with capacity 1 acts as a oneshot for the first terminal event.
933    let (runtime_event_tx, runtime_event_rx) = smol::channel::bounded::<AndroidRuntimeEvent>(1);
934
935    // Lifecycle completion is logged at info, so the internal stream must include info even
936    // when terminal log display is disabled or configured for a stricter level.
937    let priority = match log_level {
938        Some(LogLevel::Debug) => 'D',
939        Some(LogLevel::Verbose) => 'V',
940        Some(LogLevel::Error | LogLevel::Warn | LogLevel::Info) | None => 'I',
941    };
942
943    // Build logcat command with PID filter and minimum priority
944    let pid_arg = format!("--pid={pid}");
945    let mut cmd = host.command(adb.path());
946    cmd.args(["-s", device_id, "logcat", "-v", "threadtime"])
947        .arg(pid_arg)
948        .arg(format!("*:{priority}"))
949        .stdout(std::process::Stdio::piped())
950        .stderr(std::process::Stdio::null());
951
952    let mut child = match cmd.spawn() {
953        Ok(c) => c,
954        Err(e) => {
955            tracing::warn!("Failed to spawn logcat: {e}");
956            return runtime_event_rx;
957        }
958    };
959
960    let Some(stdout) = child.stdout.take() else {
961        return runtime_event_rx;
962    };
963
964    let reader = BufReader::new(stdout);
965    let mut lines = reader.lines();
966
967    spawn(async move {
968        // Parse logcat output and send as DeviceEvent::Log
969        // Logcat format: "MM-DD HH:MM:SS.mmm  PID  TID LEVEL TAG: message"
970        while let Some(result) = lines.next().await {
971            let Ok(line) = result else { break };
972
973            // Extract the first terminal event directly from the live process-filtered stream.
974            if let Some(event) = android_runtime_event_from_log_line(&line) {
975                let _ = runtime_event_tx.try_send(event);
976            }
977
978            // Only send log events to display if user requested logs
979            if let Some(requested_level) = log_level {
980                let (parsed_level, message) = parse_logcat_line(&line);
981
982                if log_level_allows(requested_level, parsed_level)
983                    && sender
984                        .try_send(DeviceEvent::Log {
985                            level: parsed_level,
986                            message,
987                        })
988                        .is_err()
989                {
990                    break;
991                }
992            }
993        }
994
995        // Clean up child process
996        let _ = child.kill();
997    })
998    .detach();
999
1000    runtime_event_rx
1001}
1002
1003fn android_runtime_event_from_log_line(line: &str) -> Option<AndroidRuntimeEvent> {
1004    if line.contains("panic.payload=")
1005        && let Some(info) = extract_panic_info_from_log(line)
1006    {
1007        return Some(AndroidRuntimeEvent::Panic(info));
1008    }
1009    if android_log_line_looks_like_crash(line) {
1010        return Some(AndroidRuntimeEvent::NativeCrash(line.to_string()));
1011    }
1012    if line.contains(ANDROID_ACTIVITY_FINISHED_MARKER) {
1013        return Some(AndroidRuntimeEvent::ActivityFinished);
1014    }
1015    None
1016}
1017
1018fn log_level_allows(requested: LogLevel, actual: tracing::Level) -> bool {
1019    match requested {
1020        LogLevel::Error => actual == tracing::Level::ERROR,
1021        LogLevel::Warn => matches!(actual, tracing::Level::ERROR | tracing::Level::WARN),
1022        LogLevel::Info => matches!(
1023            actual,
1024            tracing::Level::ERROR | tracing::Level::WARN | tracing::Level::INFO
1025        ),
1026        LogLevel::Debug => actual != tracing::Level::TRACE,
1027        LogLevel::Verbose => true,
1028    }
1029}
1030
1031/// Extract panic information from a log line containing panic.payload and panic.location fields.
1032fn extract_panic_info_from_log(line: &str) -> Option<PanicInfo> {
1033    let mut payload = None;
1034    let mut location = None;
1035
1036    // Extract panic.payload="..."
1037    if let Some(start) = line.find("panic.payload=\"") {
1038        let start = start + 15;
1039        if let Some(end) = line[start..].find('"') {
1040            payload = Some(line[start..start + end].to_string());
1041        }
1042    }
1043
1044    // Extract panic.location="..."
1045    if let Some(start) = line.find("panic.location=\"") {
1046        let start = start + 16;
1047        if let Some(end) = line[start..].find('"') {
1048            location = Some(line[start..start + end].to_string());
1049        }
1050    }
1051
1052    payload.map(|p| PanicInfo {
1053        payload: p,
1054        location,
1055    })
1056}
1057
1058/// Parsed logcat line with level, tag, and message.
1059struct LogcatParsed {
1060    level: tracing::Level,
1061    tag: String,
1062    message: String,
1063}
1064
1065/// Parse a logcat line into level, tag, and message.
1066/// Logcat threadtime format: "MM-DD HH:MM:SS.mmm  PID  TID LEVEL TAG: message"
1067fn parse_logcat_line(line: &str) -> (tracing::Level, String) {
1068    // Try to parse the structured format
1069    if let Some(parsed) = try_parse_logcat(line) {
1070        let formatted = format!("[{}] {}", parsed.tag, parsed.message);
1071        return (parsed.level, formatted);
1072    }
1073
1074    // Fallback: return raw line with default level
1075    (tracing::Level::INFO, line.to_string())
1076}
1077
1078/// Try to parse a logcat line. Returns None if parsing fails.
1079fn try_parse_logcat(line: &str) -> Option<LogcatParsed> {
1080    // Logcat threadtime format: "MM-DD HH:MM:SS.mmm  PID  TID LEVEL TAG: message"
1081    // Example: "12-10 23:04:40.190 28184 28184 D WaterUI : Touch..."
1082
1083    // Split by whitespace, but we need to be careful about the message part
1084    let parts: Vec<&str> = line.splitn(7, char::is_whitespace).collect();
1085
1086    // We need at least: date, time, pid, tid, level, tag, message
1087    if parts.len() < 6 {
1088        return None;
1089    }
1090
1091    // Find the level character (should be single char: V, D, I, W, E, F)
1092    let mut level_idx = None;
1093    for (i, part) in parts.iter().enumerate() {
1094        if part.len() == 1 {
1095            let c = part.chars().next()?;
1096            if matches!(c, 'V' | 'D' | 'I' | 'W' | 'E' | 'F') {
1097                level_idx = Some(i);
1098                break;
1099            }
1100        }
1101    }
1102
1103    let level_idx = level_idx?;
1104    if level_idx + 1 >= parts.len() {
1105        return None;
1106    }
1107
1108    let level = match parts[level_idx] {
1109        "E" | "F" => tracing::Level::ERROR,
1110        "W" => tracing::Level::WARN,
1111        "D" => tracing::Level::DEBUG,
1112        "V" => tracing::Level::TRACE,
1113        _ => tracing::Level::INFO,
1114    };
1115
1116    // The rest after level is "TAG: message" or "TAG     : message"
1117    // Find the position of the level character in the original line (after timestamp)
1118    // Skip past timestamp "MM-DD HH:MM:SS.mmm" which is about 18 chars
1119    let level_char = parts[level_idx].chars().next()?;
1120    let search_start = 18.min(line.len());
1121    let level_pos = line[search_start..]
1122        .find(level_char)
1123        .map(|p| p + search_start)?;
1124
1125    let after_level = line.get(level_pos + 1..)?.trim_start();
1126
1127    // Split by ": " to get tag and message
1128    after_level.find(": ").map_or_else(
1129        || {
1130            Some(LogcatParsed {
1131                level,
1132                tag: "unknown".to_string(),
1133                message: after_level.to_string(),
1134            })
1135        },
1136        |colon_pos| {
1137            let tag = after_level[..colon_pos].trim();
1138            let message = after_level[colon_pos + 2..].to_string();
1139            Some(LogcatParsed {
1140                level,
1141                tag: tag.to_string(),
1142                message,
1143            })
1144        },
1145    )
1146}
1147
1148/// Android emulator (AVD) that needs to be launched.
1149///
1150/// Unlike `AndroidDevice` which represents an already-connected device,
1151/// `AndroidEmulator` represents an AVD that will be launched when `launch()` is called.
1152#[derive(Debug)]
1153pub struct AndroidEmulator {
1154    /// AVD name.
1155    avd_name: String,
1156    expected_abi: AndroidAbi,
1157    device: OnceLock<AndroidDevice>,
1158}
1159
1160impl AndroidEmulator {
1161    /// Open an Android emulator definition by AVD name (reads config to determine ABI).
1162    ///
1163    /// # Errors
1164    /// Returns an error if the AVD config is missing or malformed.
1165    pub async fn open(host: &Host, avd_name: String) -> eyre::Result<Self> {
1166        let expected_abi = read_avd_abi(host, &avd_name).await?;
1167        Ok(Self {
1168            avd_name,
1169            expected_abi,
1170            device: OnceLock::new(),
1171        })
1172    }
1173
1174    /// Get the AVD name.
1175    #[must_use]
1176    pub fn avd_name(&self) -> &str {
1177        &self.avd_name
1178    }
1179
1180    #[must_use]
1181    /// ABI expected for this AVD (from its config).
1182    pub const fn expected_abi(&self) -> AndroidAbi {
1183        self.expected_abi
1184    }
1185}
1186
1187impl Device for AndroidEmulator {
1188    fn name(&self) -> &str {
1189        &self.avd_name
1190    }
1191
1192    async fn launch(&self, host: &Host) -> eyre::Result<()> {
1193        let emulator_path = AndroidSdk::emulator_path(host)
1194            .ok_or_else(|| eyre::eyre!("Android emulator not found"))?;
1195        let adb = Adb::locate(host).await?;
1196
1197        let mut emulator_process = if avd_process_is_running(&self.avd_name).await {
1198            debug!(
1199                "AVD '{}' already has a running emulator process; waiting for it to become ready",
1200                self.avd_name
1201            );
1202            None
1203        } else {
1204            // Start the emulator process (don't wait for it here, we'll poll for readiness).
1205            // Use std::process::Command so we can isolate process-group behavior.
1206            let mut emulator_cmd = host.std_command(&emulator_path);
1207            emulator_cmd
1208                .arg("-avd")
1209                .arg(&self.avd_name)
1210                .arg("-no-snapshot-load")
1211                .stdout(std::process::Stdio::null())
1212                .stderr(std::process::Stdio::null());
1213
1214            #[cfg(unix)]
1215            {
1216                use std::os::unix::process::CommandExt as _;
1217                // Move emulator into its own process group so Ctrl+C in `water run`
1218                // only stops the CLI/app and doesn't terminate the emulator process.
1219                emulator_cmd.process_group(0);
1220            }
1221
1222            Some(smol::unblock(move || emulator_cmd.spawn()).await?)
1223        };
1224
1225        let start = std::time::Instant::now();
1226        let timeout = std::time::Duration::from_mins(5);
1227        let mut last_emulator_states = String::new();
1228
1229        loop {
1230            if let Some(process) = emulator_process.as_mut()
1231                && let Some(status) = process.try_wait()?
1232            {
1233                if avd_process_is_running(&self.avd_name).await {
1234                    debug!(
1235                        "Launched emulator process exited with {status}, but another process owns AVD '{}'; waiting for that instance",
1236                        self.avd_name
1237                    );
1238                    emulator_process = None;
1239                } else {
1240                    eyre::bail!(
1241                        "Emulator process exited before becoming ready (status: {status}). Check AVD configuration and run `emulator -avd {}` manually for details.",
1242                        self.avd_name
1243                    );
1244                }
1245            } else if emulator_process.is_none() && !avd_process_is_running(&self.avd_name).await {
1246                eyre::bail!(
1247                    "Existing emulator process for AVD '{}' exited before becoming ready.",
1248                    self.avd_name
1249                );
1250            }
1251
1252            if start.elapsed() > timeout {
1253                let states = if last_emulator_states.is_empty() {
1254                    "no emulator device reported by adb".to_string()
1255                } else {
1256                    last_emulator_states.clone()
1257                };
1258
1259                eyre::bail!(
1260                    "Emulator launch timed out after 300 seconds (ADB state: {}).",
1261                    states
1262                );
1263            }
1264
1265            last_emulator_states = match adb_emulator_states(host, &adb).await {
1266                Ok(states) => states,
1267                Err(err) => format!("failed to query emulator state via adb: {err}"),
1268            };
1269
1270            if let Some(device) =
1271                try_find_running_emulator_for_avd(host, &adb, &self.avd_name).await?
1272            {
1273                if device.abi() != self.expected_abi {
1274                    eyre::bail!(
1275                        "AVD '{}' expected ABI {}, but running emulator reports {}",
1276                        self.avd_name,
1277                        self.expected_abi.as_str(),
1278                        device.abi().as_str()
1279                    );
1280                }
1281
1282                let emulator_id = device.identifier().to_string();
1283                let boot_completed = adb_emulator_boot_completed(host, &adb, &emulator_id).await;
1284                let package_ready = adb_package_manager_ready(host, &adb, &emulator_id).await;
1285
1286                if !boot_completed || !package_ready {
1287                    debug!(
1288                        "Emulator {} detected but not fully ready yet (boot_completed={}, package_ready={})",
1289                        emulator_id, boot_completed, package_ready
1290                    );
1291                    smol::Timer::after(std::time::Duration::from_secs(2)).await;
1292                    continue;
1293                }
1294
1295                self.device
1296                    .set(device)
1297                    .map_err(|_| eyre::eyre!("Emulator device already initialized"))?;
1298                return Ok(());
1299            }
1300
1301            smol::Timer::after(std::time::Duration::from_secs(2)).await;
1302        }
1303    }
1304
1305    async fn run(
1306        &self,
1307        host: &Host,
1308        artifact: Artifact,
1309        options: RunOptions,
1310    ) -> Result<Running, FailToRun> {
1311        let device = self.device.get().ok_or_else(|| {
1312            FailToRun::Run(eyre!(
1313                "Android emulator '{}' is not launched. Launch it before running.",
1314                self.avd_name
1315            ))
1316        })?;
1317        run_on_android(host, device.identifier(), artifact, options).await
1318    }
1319
1320    async fn scan(host: &Host) -> eyre::Result<Vec<Self>> {
1321        // List available AVDs using avdmanager or emulator -list-avds
1322        let emulator_path = AndroidSdk::emulator_path(host)
1323            .ok_or_else(|| eyre::eyre!("Android emulator not found"))?;
1324
1325        let output = host
1326            .output(&emulator_path, ["-list-avds"])
1327            .await
1328            .map_err(|e| eyre!("Failed to list AVDs: {e}"))?;
1329
1330        if !output.status.success() {
1331            let stderr = String::from_utf8_lossy(&output.stderr);
1332            eyre::bail!("Failed to list AVDs: {}", stderr.trim());
1333        }
1334
1335        let stdout = String::from_utf8_lossy(&output.stdout);
1336        let mut avds = Vec::new();
1337        for name in stdout.lines().map(str::trim).filter(|l| !l.is_empty()) {
1338            avds.push(Self::open(host, name.to_string()).await?);
1339        }
1340
1341        Ok(avds)
1342    }
1343}
1344
1345async fn read_avd_abi(host: &Host, avd_name: &str) -> eyre::Result<AndroidAbi> {
1346    let home = host
1347        .home_dir()
1348        .ok_or_else(|| eyre!("Failed to resolve home directory"))?;
1349    let config_path = home
1350        .join(".android/avd")
1351        .join(format!("{avd_name}.avd"))
1352        .join("config.ini");
1353
1354    let content = smol::fs::read_to_string(&config_path)
1355        .await
1356        .map_err(|e| eyre!("Failed to read AVD config {}: {e}", config_path.display()))?;
1357
1358    let abi_value = content
1359        .lines()
1360        .filter_map(|line| line.split_once('=').map(|(k, v)| (k.trim(), v.trim())))
1361        .find_map(|(k, v)| (k == "abi.type").then_some(v))
1362        .ok_or_else(|| eyre!("AVD config {} missing key abi.type", config_path.display()))?;
1363
1364    abi_value
1365        .parse::<AndroidAbi>()
1366        .map_err(|e| eyre!("Unsupported AVD ABI '{abi_value}': {e}"))
1367}
1368
1369/// Capture a screenshot from an Android device.
1370///
1371/// Uses `adb exec-out screencap -p` to capture the screen and writes
1372/// the PNG data directly to the output file.
1373///
1374/// # Errors
1375///
1376/// Returns an error if the screenshot command fails, the device is not
1377/// available, or the output file cannot be written.
1378pub async fn screenshot(host: &Host, device_id: &str, output: &Path) -> eyre::Result<()> {
1379    let adb = Adb::locate(host).await?;
1380
1381    let output_result = run_bounded_adb_output(
1382        host,
1383        &adb,
1384        ["-s", device_id, "exec-out", "screencap", "-p"],
1385        "capturing the Android device screen",
1386    )
1387    .await?;
1388
1389    if !output_result.status.success() {
1390        let stderr = String::from_utf8_lossy(&output_result.stderr);
1391        eyre::bail!("Failed to capture screenshot: {}", stderr.trim());
1392    }
1393
1394    // Write the PNG data to the output file
1395    let mut file = smol::fs::File::create(output).await?;
1396    file.write_all(&output_result.stdout).await?;
1397    file.flush().await?;
1398
1399    Ok(())
1400}
1401
1402/// Perform a tap gesture on an Android device at the specified coordinates.
1403///
1404/// Uses `adb shell input tap <x> <y>` to simulate a touch event.
1405///
1406/// # Errors
1407///
1408/// Returns an error if the tap command fails or the device is not available.
1409pub async fn tap(host: &Host, device_id: &str, x: u32, y: u32) -> eyre::Result<()> {
1410    let adb = Adb::locate(host).await?;
1411
1412    run_bounded_adb_command(
1413        host,
1414        &adb,
1415        [
1416            "-s",
1417            device_id,
1418            "shell",
1419            "input",
1420            "tap",
1421            &x.to_string(),
1422            &y.to_string(),
1423        ],
1424        "performing an Android tap",
1425    )
1426    .await?;
1427
1428    Ok(())
1429}
1430
1431/// Perform a swipe gesture on an Android device.
1432///
1433/// Uses `adb shell input swipe <x1> <y1> <x2> <y2> [duration_ms]` to simulate a swipe.
1434///
1435/// # Arguments
1436///
1437/// * `device_id` - The Android device identifier
1438/// * `from` - Starting coordinates (x, y)
1439/// * `to` - Ending coordinates (x, y)
1440/// * `duration_ms` - Optional duration in milliseconds (default ~300ms if not specified)
1441///
1442/// # Errors
1443///
1444/// Returns an error if the swipe command fails or the device is not available.
1445pub async fn swipe(
1446    host: &Host,
1447    device_id: &str,
1448    from: (u32, u32),
1449    to: (u32, u32),
1450    duration_ms: Option<u32>,
1451) -> eyre::Result<()> {
1452    let adb = Adb::locate(host).await?;
1453
1454    let mut args = vec!["-s", device_id, "shell", "input", "swipe"];
1455
1456    let x1 = from.0.to_string();
1457    let y1 = from.1.to_string();
1458    let x2 = to.0.to_string();
1459    let y2 = to.1.to_string();
1460    let duration = duration_ms.map(|d| d.to_string());
1461
1462    args.push(&x1);
1463    args.push(&y1);
1464    args.push(&x2);
1465    args.push(&y2);
1466
1467    if let Some(ref d) = duration {
1468        args.push(d);
1469    }
1470
1471    run_bounded_adb_command(host, &adb, args, "performing an Android swipe").await?;
1472
1473    Ok(())
1474}
1475
1476/// Input text on an Android device.
1477///
1478/// Uses `adb shell input text "<string>"` to type text.
1479/// Note: Special characters may need escaping.
1480///
1481/// # Errors
1482///
1483/// Returns an error if the text input command fails or the device is not available.
1484pub async fn text(host: &Host, device_id: &str, input: &str) -> eyre::Result<()> {
1485    let adb = Adb::locate(host).await?;
1486
1487    // Escape special characters for shell
1488    let escaped = input
1489        .replace('\\', "\\\\")
1490        .replace(' ', "%s")
1491        .replace('"', "\\\"")
1492        .replace('\'', "\\'")
1493        .replace('&', "\\&")
1494        .replace('<', "\\<")
1495        .replace('>', "\\>")
1496        .replace('|', "\\|")
1497        .replace(';', "\\;")
1498        .replace('(', "\\(")
1499        .replace(')', "\\)");
1500
1501    run_bounded_adb_command(
1502        host,
1503        &adb,
1504        ["-s", device_id, "shell", "input", "text", &escaped],
1505        "entering text on an Android device",
1506    )
1507    .await?;
1508
1509    Ok(())
1510}
1511
1512/// Capture a screenshot from an Android device and return the raw PNG bytes.
1513///
1514/// This is used for the diff workflow where we need in-memory screenshots.
1515///
1516/// # Errors
1517///
1518/// Returns an error if the screenshot command fails or the device is not available.
1519pub async fn screenshot_bytes(host: &Host, device_id: &str) -> eyre::Result<Vec<u8>> {
1520    let adb = Adb::locate(host).await?;
1521
1522    let output = run_bounded_adb_output(
1523        host,
1524        &adb,
1525        ["-s", device_id, "exec-out", "screencap", "-p"],
1526        "capturing the Android device screen",
1527    )
1528    .await?;
1529
1530    if !output.status.success() {
1531        let stderr = String::from_utf8_lossy(&output.stderr);
1532        eyre::bail!("Failed to capture screenshot: {}", stderr.trim());
1533    }
1534
1535    Ok(output.stdout)
1536}
1537
1538/// Describe UI elements on the screen.
1539///
1540/// Uses `uiautomator dump` to get UI hierarchy as XML, then converts to JSON.
1541///
1542/// # Errors
1543///
1544/// Returns an error if adb is not available or the command fails.
1545pub async fn describe(host: &Host, device_id: &str) -> eyre::Result<String> {
1546    let adb = Adb::locate(host).await?;
1547
1548    // Dump UI hierarchy to a temp file on device
1549    let dump_path = "/sdcard/window_dump.xml";
1550    run_bounded_adb_command(
1551        host,
1552        &adb,
1553        ["-s", device_id, "shell", "uiautomator", "dump", dump_path],
1554        "dumping the Android accessibility hierarchy",
1555    )
1556    .await?;
1557
1558    // Read the dump file
1559    let output = run_bounded_adb_output(
1560        host,
1561        &adb,
1562        ["-s", device_id, "shell", "cat", dump_path],
1563        "reading the Android accessibility hierarchy",
1564    )
1565    .await?;
1566
1567    if !output.status.success() {
1568        let stderr = String::from_utf8_lossy(&output.stderr);
1569        eyre::bail!("Failed to read UI dump: {}", stderr.trim());
1570    }
1571
1572    let xml = String::from_utf8_lossy(&output.stdout).to_string();
1573
1574    // Convert XML to simplified JSON format
1575    let json = xml_to_ui_json(&xml)?;
1576
1577    // Clean up
1578    let _ = host
1579        .run(adb.path(), ["-s", device_id, "shell", "rm", dump_path])
1580        .await;
1581
1582    Ok(json)
1583}
1584
1585/// Convert Android UI XML dump to a JSON format similar to iOS IDB output.
1586fn xml_to_ui_json(xml: &str) -> eyre::Result<String> {
1587    let mut elements = Vec::new();
1588
1589    // Simple XML parsing - find all <node> elements
1590    for line in xml.lines() {
1591        if !line.contains("<node") {
1592            continue;
1593        }
1594
1595        let mut element = serde_json::Map::new();
1596
1597        // Extract bounds attribute: bounds="[left,top][right,bottom]"
1598        if let Some(bounds_start) = line.find("bounds=\"[") {
1599            let bounds_str = &line[bounds_start + 8..];
1600            if let Some(bounds_end) = bounds_str.find('"') {
1601                let bounds = &bounds_str[..bounds_end];
1602                // Parse [left,top][right,bottom]
1603                let parts: Vec<&str> = bounds
1604                    .trim_matches(|c| c == '[' || c == ']')
1605                    .split("][")
1606                    .collect();
1607                if parts.len() == 2 {
1608                    let lt: Vec<i32> = parts[0].split(',').filter_map(|s| s.parse().ok()).collect();
1609                    let rb: Vec<i32> = parts[1].split(',').filter_map(|s| s.parse().ok()).collect();
1610                    if lt.len() == 2 && rb.len() == 2 {
1611                        let mut frame = serde_json::Map::new();
1612                        frame.insert("x".to_string(), serde_json::Value::Number(lt[0].into()));
1613                        frame.insert("y".to_string(), serde_json::Value::Number(lt[1].into()));
1614                        frame.insert(
1615                            "width".to_string(),
1616                            serde_json::Value::Number((rb[0] - lt[0]).into()),
1617                        );
1618                        frame.insert(
1619                            "height".to_string(),
1620                            serde_json::Value::Number((rb[1] - lt[1]).into()),
1621                        );
1622                        element.insert("frame".to_string(), serde_json::Value::Object(frame));
1623                    }
1624                }
1625            }
1626        }
1627
1628        // Extract common attributes
1629        for attr in ["text", "content-desc", "class", "resource-id"] {
1630            let search = format!("{attr}=\"");
1631            if let Some(start) = line.find(&search) {
1632                let value_start = start + search.len();
1633                let rest = &line[value_start..];
1634                if let Some(end) = rest.find('"') {
1635                    let value = &rest[..end];
1636                    if !value.is_empty() {
1637                        let key = match attr {
1638                            "content-desc" => "AXLabel",
1639                            "class" => "type",
1640                            "resource-id" => "AXUniqueId",
1641                            "text" => "AXValue",
1642                            _ => attr,
1643                        };
1644                        element.insert(
1645                            key.to_string(),
1646                            serde_json::Value::String(value.to_string()),
1647                        );
1648                    }
1649                }
1650            }
1651        }
1652
1653        // Extract clickable/enabled attributes
1654        if line.contains("clickable=\"true\"") {
1655            element.insert("clickable".to_string(), serde_json::Value::Bool(true));
1656        }
1657        if line.contains("enabled=\"true\"") {
1658            element.insert("enabled".to_string(), serde_json::Value::Bool(true));
1659        }
1660
1661        if !element.is_empty() {
1662            elements.push(serde_json::Value::Object(element));
1663        }
1664    }
1665
1666    serde_json::to_string(&elements).map_err(|e| eyre!("Failed to serialize UI elements: {e}"))
1667}
1668
1669#[cfg(test)]
1670mod tests {
1671    use std::ffi::OsString;
1672
1673    use super::{
1674        AndroidRuntimeEvent, adb_reports_device_ready, android_log_looks_like_crash,
1675        android_runtime_event_from_log_line, command_targets_avd, log_level_allows,
1676        log_mentions_pid,
1677    };
1678    use crate::device::LogLevel;
1679
1680    #[test]
1681    fn detects_pid_mentions_in_threadtime_lines() {
1682        let log = "12-10 23:04:40.190 28184 28184 F libc    : Fatal signal 11 (SIGSEGV)\n";
1683        assert!(log_mentions_pid(log, 28184));
1684        assert!(!log_mentions_pid(log, 12345));
1685    }
1686
1687    #[test]
1688    fn avoids_false_positive_from_unrelated_fatal_signal_in_global_dump() {
1689        let unrelated = "12-10 23:04:40.190 999 999 F libc    : Fatal signal 11 (SIGSEGV)\n";
1690        assert!(!android_log_looks_like_crash(
1691            unrelated,
1692            "com.example.app",
1693            28184
1694        ));
1695    }
1696
1697    #[test]
1698    fn detects_native_crash_when_pid_is_mentioned() {
1699        let log = "I DEBUG : Fatal signal 11 (SIGSEGV), code 1, fault addr 0x0 in tid 1 (main) pid: 28184\n";
1700        assert!(android_log_looks_like_crash(log, "com.example.app", 28184));
1701    }
1702
1703    #[test]
1704    fn detects_java_crash_for_app() {
1705        let log = "E AndroidRuntime: FATAL EXCEPTION: main\nE AndroidRuntime: Process: com.example.app, PID: 28184\n";
1706        assert!(android_log_looks_like_crash(log, "com.example.app", 28184));
1707    }
1708
1709    #[test]
1710    fn detects_activity_completion_marker() {
1711        let event = android_runtime_event_from_log_line(
1712            "07-26 20:00:00.000 28184 28184 I WaterUI.MainActivity: WATERUI_ACTIVITY_FINISHED",
1713        );
1714        assert!(matches!(event, Some(AndroidRuntimeEvent::ActivityFinished)));
1715    }
1716
1717    #[test]
1718    fn filters_internal_lifecycle_logs_from_stricter_user_log_levels() {
1719        assert!(!log_level_allows(LogLevel::Error, tracing::Level::INFO));
1720        assert!(!log_level_allows(LogLevel::Warn, tracing::Level::INFO));
1721        assert!(log_level_allows(LogLevel::Info, tracing::Level::INFO));
1722        assert!(log_level_allows(LogLevel::Debug, tracing::Level::DEBUG));
1723        assert!(log_level_allows(LogLevel::Verbose, tracing::Level::TRACE));
1724    }
1725
1726    #[test]
1727    fn matches_emulator_process_to_exact_avd_argument() {
1728        let command = [
1729            OsString::from("qemu-system-aarch64"),
1730            OsString::from("-netdelay"),
1731            OsString::from("none"),
1732            OsString::from("-avd"),
1733            OsString::from("Pixel_9"),
1734        ];
1735
1736        assert!(command_targets_avd(&command, "Pixel_9".as_ref()));
1737        assert!(!command_targets_avd(&command, "Pixel_9_Pro".as_ref()));
1738    }
1739
1740    #[test]
1741    fn does_not_match_unrelated_avd_text() {
1742        let command = [
1743            OsString::from("emulator-helper"),
1744            OsString::from("--log"),
1745            OsString::from("starting Pixel_9"),
1746        ];
1747
1748        assert!(!command_targets_avd(&command, "Pixel_9".as_ref()));
1749    }
1750
1751    #[test]
1752    fn parses_ready_android_device_state() {
1753        let output = "List of devices attached\nemulator-5554 device product:sdk_phone64_arm64 transport_id:1\n";
1754
1755        assert!(adb_reports_device_ready(output, "emulator-5554"));
1756        assert!(!adb_reports_device_ready(output, "emulator-5556"));
1757    }
1758
1759    #[test]
1760    fn rejects_offline_android_device_state() {
1761        let output = "List of devices attached\nemulator-5554 offline transport_id:1\n";
1762
1763        assert!(!adb_reports_device_ready(output, "emulator-5554"));
1764    }
1765}