waterui_cli/android/
device.rs

1use color_eyre::eyre::{self, eyre};
2use smol::process::Command;
3use tracing::error;
4
5use std::process::Stdio;
6
7use crate::{
8    android::{platform::AndroidPlatform, toolchain::AndroidSdk},
9    device::{Artifact, Device, DeviceEvent, FailToRun, LogLevel, RunOptions, Running},
10    utils::{parse_whitespace_separated_u32s, run_command, run_command_output},
11};
12
13/// Represents an Android device (physical or emulator).
14#[derive(Debug)]
15pub struct AndroidDevice {
16    identifier: String,
17    /// Primary ABI of the device (e.g., "arm64-v8a", "`x86_64`")
18    abi: String,
19}
20
21impl AndroidDevice {
22    /// Create a new Android device with the given identifier and ABI.
23    #[must_use]
24    pub const fn new(identifier: String, abi: String) -> Self {
25        Self { identifier, abi }
26    }
27
28    /// Get the device identifier.
29    #[must_use]
30    pub fn identifier(&self) -> &str {
31        &self.identifier
32    }
33
34    /// Get the device's primary ABI.
35    #[must_use]
36    pub fn abi(&self) -> &str {
37        &self.abi
38    }
39}
40
41impl Device for AndroidDevice {
42    type Platform = AndroidPlatform;
43
44    async fn launch(&self) -> eyre::Result<()> {
45        let adb = AndroidSdk::adb_path()
46            .ok_or_else(|| eyre::eyre!("Android SDK not found or adb not installed"))?;
47        run_command(
48            adb.to_str().unwrap(),
49            ["-s", &self.identifier, "wait-for-device"],
50        )
51        .await?;
52        Ok(())
53    }
54
55    fn platform(&self) -> Self::Platform {
56        AndroidPlatform::from_abi(&self.abi)
57    }
58
59    async fn run(&self, artifact: Artifact, options: RunOptions) -> Result<Running, FailToRun> {
60        run_on_android(&self.identifier, artifact, options).await
61    }
62}
63
64/// Shared implementation for running an app on any Android device.
65///
66/// This handles:
67/// - Passing environment variables as intent extras
68/// - Uninstalling previous version (to avoid storage issues)
69/// - Installing the APK
70/// - Launching the app
71/// - Monitoring process state
72/// - Streaming logs
73#[allow(clippy::too_many_lines)]
74async fn run_on_android(
75    device_id: &str,
76    artifact: Artifact,
77    options: RunOptions,
78) -> Result<Running, FailToRun> {
79    let adb = AndroidSdk::adb_path()
80        .ok_or_else(|| FailToRun::Run(eyre!("Android SDK not found or adb not installed")))?;
81    let adb_str = adb.to_str().unwrap();
82
83    let env_vars: Vec<(String, String)> = options
84        .env_vars()
85        .map(|(k, v)| (k.to_string(), v.to_string()))
86        .collect();
87
88    // If hot reload is using localhost, set up adb reverse so the device can connect back
89    // to the host's hot reload server (listening on 127.0.0.1:<port>).
90    let reverse_port = env_vars
91        .iter()
92        .find(|(k, _)| k == "WATERUI_HOT_RELOAD_PORT")
93        .and_then(|(_, v)| v.parse::<u16>().ok())
94        .zip(
95            env_vars
96                .iter()
97                .find(|(k, _)| k == "WATERUI_HOT_RELOAD_HOST")
98                .map(|(_, v)| v.as_str()),
99        )
100        .and_then(|(port, host)| {
101            if host == "127.0.0.1" || host == "localhost" {
102                Some(port)
103            } else {
104                None
105            }
106        });
107
108    if let Some(port) = reverse_port {
109        let spec = format!("tcp:{port}");
110        let output = Command::new(adb_str)
111            .args(["-s", device_id, "reverse", &spec, &spec])
112            .stdout(Stdio::piped())
113            .stderr(Stdio::piped())
114            .output()
115            .await;
116
117        match output {
118            Ok(output) if output.status.success() => {}
119            Ok(output) => {
120                tracing::warn!(
121                    "Failed to set up adb reverse for hot reload ({}): stdout='{}' stderr='{}'",
122                    spec,
123                    String::from_utf8_lossy(&output.stdout).trim(),
124                    String::from_utf8_lossy(&output.stderr).trim()
125                );
126            }
127            Err(e) => {
128                tracing::warn!("Failed to set up adb reverse for hot reload ({spec}): {e}");
129            }
130        }
131    }
132
133    // Install the APK on the device with -r flag to replace existing installation
134    // This handles both cases: fresh install and reinstall over existing app
135    run_command(
136        adb_str,
137        [
138            "-s",
139            device_id,
140            "install",
141            "-r",
142            artifact.path().to_str().unwrap(),
143        ],
144    )
145    .await
146    .map_err(|e| FailToRun::Install(eyre!("Failed to install APK: {e}")))?;
147
148    // Launch the app (pass env vars as intent extras).
149    //
150    // We use the "waterui.env.<KEY>" namespace to avoid collisions.
151    // MainActivity reads these extras and calls Os.setenv() before loading native libraries.
152    let mut start_args = vec![
153        "-s".to_string(),
154        device_id.to_string(),
155        "shell".to_string(),
156        "am".to_string(),
157        "start".to_string(),
158        "-S".to_string(), // force-stop target app before starting (ensures env takes effect)
159        "-n".to_string(),
160        format!("{}/.MainActivity", artifact.bundle_id()),
161    ];
162
163    for (key, value) in &env_vars {
164        start_args.push("--es".to_string());
165        start_args.push(format!("waterui.env.{key}"));
166        start_args.push(value.clone());
167    }
168
169    let output = Command::new(adb_str)
170        .args(&start_args)
171        .stdout(Stdio::piped())
172        .stderr(Stdio::piped())
173        .output()
174        .await
175        .map_err(|e| FailToRun::Launch(eyre!("Failed to launch app: {e}")))?;
176
177    if !output.status.success() {
178        return Err(FailToRun::Launch(eyre!(
179            "Failed to launch app:\n{}\n{}",
180            String::from_utf8_lossy(&output.stdout).trim(),
181            String::from_utf8_lossy(&output.stderr).trim(),
182        )));
183    }
184
185    // Wait for the process to start and get its PID
186    let pid = wait_for_app_pid(adb_str, device_id, artifact.bundle_id()).await?;
187
188    let adb_for_kill = adb.clone();
189    let identifier_for_kill = device_id.to_string();
190    let identifier_for_monitor = device_id.to_string();
191    let bundle_id_for_kill = artifact.bundle_id().to_string();
192    let bundle_id_for_monitor = artifact.bundle_id().to_string();
193    let log_level = options.log_level();
194    let reverse_port_for_drop = reverse_port;
195
196    let (running, sender) = Running::new(move || {
197        // Use std::process::Command for synchronous execution in Drop context
198        let result = std::process::Command::new(&adb_for_kill)
199            .args([
200                "-s",
201                &identifier_for_kill,
202                "shell",
203                "am",
204                "force-stop",
205                &bundle_id_for_kill,
206            ])
207            .output();
208
209        match result {
210            Ok(output) => {
211                tracing::debug!(
212                    "Force-stop command executed: status={}, stdout={}, stderr={}",
213                    output.status,
214                    String::from_utf8_lossy(&output.stdout),
215                    String::from_utf8_lossy(&output.stderr)
216                );
217            }
218            Err(e) => {
219                error!("Failed to stop app {}: {}", bundle_id_for_kill, e);
220            }
221        }
222
223        if let Some(port) = reverse_port_for_drop {
224            let spec = format!("tcp:{port}");
225            let _ = std::process::Command::new(&adb_for_kill)
226                .args(["-s", &identifier_for_kill, "reverse", "--remove", &spec])
227                .output();
228        }
229    });
230
231    // Spawn a background task to monitor the process
232    let adb_for_monitor = adb.clone();
233    let sender_for_monitor = sender.clone();
234    smol::spawn(async move {
235        monitor_android_process(
236            adb_for_monitor,
237            &identifier_for_monitor,
238            &bundle_id_for_monitor,
239            pid,
240            sender_for_monitor,
241        )
242        .await;
243    })
244    .detach();
245
246    // Spawn a background task to stream logs if log_level is set
247    if let Some(level) = log_level {
248        let adb_for_logs = adb;
249        let identifier_for_logs = device_id.to_string();
250        smol::spawn(async move {
251            stream_android_logs(adb_for_logs, &identifier_for_logs, pid, level, sender).await;
252        })
253        .detach();
254    }
255
256    Ok(running)
257}
258
259/// Wait for an app to start and return its PID.
260async fn wait_for_app_pid(
261    adb_str: &str,
262    device_id: &str,
263    bundle_id: &str,
264) -> Result<u32, FailToRun> {
265    for _ in 0..10 {
266        smol::Timer::after(std::time::Duration::from_millis(200)).await;
267        if let Ok(output) =
268            run_command(adb_str, ["-s", device_id, "shell", "pidof", bundle_id]).await
269        {
270            if let Some(pid) = parse_whitespace_separated_u32s(&output).into_iter().next() {
271                return Ok(pid);
272            }
273        }
274    }
275
276    // App likely crashed on startup - fetch logcat for crash info
277    let crash_info = run_command(
278        adb_str,
279        [
280            "-s",
281            device_id,
282            "logcat",
283            "-d",
284            "-t",
285            "100",
286            "-s",
287            "AndroidRuntime:E",
288            "DEBUG:*",
289            "WaterUI:*",
290        ],
291    )
292    .await
293    .unwrap_or_default();
294
295    let mut error_msg = format!("App {bundle_id} crashed on startup (process not found).\n\n");
296
297    if !crash_info.trim().is_empty() {
298        error_msg.push_str("=== Crash Log ===\n");
299        error_msg.push_str(&crash_info);
300    }
301
302    Err(FailToRun::Launch(eyre!("{}", error_msg)))
303}
304
305/// Find the running emulator's device identifier.
306async fn find_emulator_identifier() -> Result<String, FailToRun> {
307    let adb = AndroidSdk::adb_path()
308        .ok_or_else(|| FailToRun::Run(eyre!("Android SDK not found or adb not installed")))?;
309
310    let output = run_command(adb.to_str().unwrap(), ["devices"])
311        .await
312        .map_err(|e| FailToRun::Run(eyre!("Failed to list devices: {e}")))?;
313
314    output
315        .lines()
316        .skip(1)
317        .find_map(|line| {
318            let parts: Vec<&str> = line.split_whitespace().collect();
319            if parts.len() >= 2 && parts[0].starts_with("emulator-") && parts[1] == "device" {
320                Some(parts[0].to_string())
321            } else {
322                None
323            }
324        })
325        .ok_or_else(|| FailToRun::Run(eyre!("Emulator not running")))
326}
327
328/// Monitor an Android process and send events when it crashes or exits.
329async fn monitor_android_process(
330    adb: std::path::PathBuf,
331    device_id: &str,
332    bundle_id: &str,
333    pid: u32,
334    sender: smol::channel::Sender<DeviceEvent>,
335) {
336    let adb_str = adb.to_str().unwrap_or_default();
337
338    // Check process status periodically
339    loop {
340        smol::Timer::after(std::time::Duration::from_secs(1)).await;
341
342        // Check if process is still running using pidof
343        // Note: We use pidof instead of kill -0 because kill -0 returns "Operation not permitted"
344        // when the shell user doesn't have permission to send signals to the app process
345        let result = run_command(adb_str, ["-s", device_id, "shell", "pidof", bundle_id]).await;
346
347        // Check if the process with the same PID is still running
348        let still_running = result
349            .as_ref()
350            .ok()
351            .map(|output| parse_whitespace_separated_u32s(output))
352            .is_some_and(|pids| pids.contains(&pid));
353
354        if !still_running {
355            // Give crash reporting a brief moment to flush logs.
356            smol::Timer::after(std::time::Duration::from_millis(500)).await;
357
358            // Try to fetch logs for this PID (best signal for distinguishing crash vs normal exit).
359            let pid_arg = format!("--pid={pid}");
360            let pid_log_args = vec![
361                "-s".to_string(),
362                device_id.to_string(),
363                "logcat".to_string(),
364                "-v".to_string(),
365                "threadtime".to_string(),
366                "-d".to_string(),
367                "-t".to_string(),
368                "200".to_string(),
369                pid_arg,
370                "*:V".to_string(),
371            ];
372            let pid_log = run_command_output(adb_str, pid_log_args.iter().map(String::as_str))
373                .await
374                .ok()
375                .filter(|o| o.status.success())
376                .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
377                .unwrap_or_default();
378
379            // Fallback for older logcat versions that don't support --pid.
380            let fallback_log = if pid_log.trim().is_empty() {
381                let fallback_args = vec![
382                    "-s".to_string(),
383                    device_id.to_string(),
384                    "logcat".to_string(),
385                    "-v".to_string(),
386                    "threadtime".to_string(),
387                    "-d".to_string(),
388                    "-t".to_string(),
389                    "200".to_string(),
390                    "-s".to_string(),
391                    "AndroidRuntime:E".to_string(),
392                    "DEBUG:*".to_string(),
393                    "libc:F".to_string(),
394                ];
395                run_command_output(adb_str, fallback_args.iter().map(String::as_str))
396                    .await
397                    .ok()
398                    .filter(|o| o.status.success())
399                    .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
400                    .unwrap_or_default()
401            } else {
402                String::new()
403            };
404
405            let pid_filtered = !pid_log.trim().is_empty();
406            let log_for_detection = if pid_filtered {
407                pid_log.as_str()
408            } else {
409                fallback_log.as_str()
410            };
411
412            if android_log_looks_like_crash(log_for_detection, bundle_id, pid, pid_filtered) {
413                let crash_log = if pid_log.trim().is_empty() {
414                    fallback_log
415                } else {
416                    pid_log
417                };
418
419                let error_msg = if crash_log.trim().is_empty() {
420                    format!("Process {bundle_id} crashed.")
421                } else {
422                    format!("Process {bundle_id} crashed.\n\n=== Crash Log ===\n{crash_log}")
423                };
424
425                let _ = sender.send(DeviceEvent::Crashed(error_msg)).await;
426            } else {
427                let _ = sender.send(DeviceEvent::Exited).await;
428            }
429            break;
430        }
431    }
432}
433
434fn log_mentions_pid(log: &str, pid: u32) -> bool {
435    let pid_str = pid.to_string();
436    let pid_lower = format!("pid: {pid}");
437    let pid_upper = format!("PID: {pid}");
438
439    log.lines().any(|line| {
440        line.split_whitespace().any(|part| part == pid_str)
441            || line.contains(&pid_lower)
442            || line.contains(&pid_upper)
443    })
444}
445
446fn android_log_looks_like_crash(log: &str, bundle_id: &str, pid: u32, pid_filtered: bool) -> bool {
447    if log.trim().is_empty() {
448        return false;
449    }
450
451    // When we don't have a PID-filtered dump (older logcat), ensure we don't accidentally pick up
452    // crashes from unrelated processes.
453    let relevant = pid_filtered || log.contains(bundle_id) || log_mentions_pid(log, pid);
454    if !relevant {
455        return false;
456    }
457
458    // Common Java crash markers (AndroidRuntime).
459    if log.contains("FATAL EXCEPTION") {
460        return true;
461    }
462
463    // Common native crash markers (tombstone / debuggerd / libc).
464    if log.contains("Fatal signal") {
465        return true;
466    }
467    if log.contains("SIGSEGV")
468        || log.contains("SIGABRT")
469        || log.contains("SIGBUS")
470        || log.contains("SIGILL")
471        || log.contains("SIGFPE")
472    {
473        return true;
474    }
475    if log.contains("Abort message:") || log.contains("backtrace:") {
476        return true;
477    }
478
479    // If we only have a global log fallback (no --pid), make sure it actually mentions this app
480    // and includes an error marker to avoid false positives from unrelated processes.
481    if !log.contains(bundle_id) {
482        return false;
483    }
484
485    // Heuristic: treat AndroidRuntime errors for this process as crash.
486    log.contains("AndroidRuntime")
487        && (log.contains("E AndroidRuntime") || log.contains("Exception"))
488}
489
490/// Stream logs from an Android process using logcat.
491async fn stream_android_logs(
492    adb: std::path::PathBuf,
493    device_id: &str,
494    pid: u32,
495    level: LogLevel,
496    sender: smol::channel::Sender<DeviceEvent>,
497) {
498    use futures::StreamExt;
499    use futures::io::{AsyncBufReadExt, BufReader};
500    use smol::process::Command;
501
502    let priority = level.to_android_priority();
503
504    // Build logcat command with PID filter and minimum priority
505    // Format: `adb -s <device> logcat --pid=<pid> *:<priority>`
506    let pid_arg = format!("--pid={pid}");
507    let mut cmd = Command::new(&adb);
508    cmd.args(["-s", device_id, "logcat", "-v", "threadtime"])
509        .arg(pid_arg)
510        .arg(format!("*:{priority}"))
511        .stdout(std::process::Stdio::piped())
512        .stderr(std::process::Stdio::null());
513
514    let mut child = match cmd.spawn() {
515        Ok(c) => c,
516        Err(e) => {
517            tracing::warn!("Failed to spawn logcat: {e}");
518            return;
519        }
520    };
521
522    let Some(stdout) = child.stdout.take() else {
523        return;
524    };
525
526    let reader = BufReader::new(stdout);
527    let mut lines = reader.lines();
528
529    // Parse logcat output and send as DeviceEvent::Log
530    // Logcat format: "MM-DD HH:MM:SS.mmm  PID  TID LEVEL TAG: message"
531    while let Some(result) = lines.next().await {
532        let Ok(line) = result else { break };
533
534        let (parsed_level, message) = parse_logcat_line(&line);
535
536        let _ = sender
537            .send(DeviceEvent::Log {
538                level: parsed_level,
539                message,
540            })
541            .await;
542    }
543
544    // Clean up child process
545    let _ = child.kill();
546}
547
548/// Parsed logcat line with level, tag, and message.
549struct LogcatParsed {
550    level: tracing::Level,
551    tag: String,
552    message: String,
553}
554
555/// Parse a logcat line into level, tag, and message.
556/// Logcat threadtime format: "MM-DD HH:MM:SS.mmm  PID  TID LEVEL TAG: message"
557fn parse_logcat_line(line: &str) -> (tracing::Level, String) {
558    // Try to parse the structured format
559    if let Some(parsed) = try_parse_logcat(line) {
560        let formatted = format!("[{}] {}", parsed.tag, parsed.message);
561        return (parsed.level, formatted);
562    }
563
564    // Fallback: return raw line with default level
565    (tracing::Level::INFO, line.to_string())
566}
567
568/// Try to parse a logcat line. Returns None if parsing fails.
569fn try_parse_logcat(line: &str) -> Option<LogcatParsed> {
570    // Logcat threadtime format: "MM-DD HH:MM:SS.mmm  PID  TID LEVEL TAG: message"
571    // Example: "12-10 23:04:40.190 28184 28184 D WaterUI : Touch..."
572
573    // Split by whitespace, but we need to be careful about the message part
574    let parts: Vec<&str> = line.splitn(7, char::is_whitespace).collect();
575
576    // We need at least: date, time, pid, tid, level, tag, message
577    if parts.len() < 6 {
578        return None;
579    }
580
581    // Find the level character (should be single char: V, D, I, W, E, F)
582    let mut level_idx = None;
583    for (i, part) in parts.iter().enumerate() {
584        if part.len() == 1 {
585            let c = part.chars().next()?;
586            if matches!(c, 'V' | 'D' | 'I' | 'W' | 'E' | 'F') {
587                level_idx = Some(i);
588                break;
589            }
590        }
591    }
592
593    let level_idx = level_idx?;
594    if level_idx + 1 >= parts.len() {
595        return None;
596    }
597
598    let level = match parts[level_idx] {
599        "E" | "F" => tracing::Level::ERROR,
600        "W" => tracing::Level::WARN,
601        "D" => tracing::Level::DEBUG,
602        "V" => tracing::Level::TRACE,
603        _ => tracing::Level::INFO,
604    };
605
606    // The rest after level is "TAG: message" or "TAG     : message"
607    // Find the position of the level character in the original line (after timestamp)
608    // Skip past timestamp "MM-DD HH:MM:SS.mmm" which is about 18 chars
609    let level_char = parts[level_idx].chars().next()?;
610    let search_start = 18.min(line.len());
611    let level_pos = line[search_start..]
612        .find(level_char)
613        .map(|p| p + search_start)?;
614
615    let after_level = line.get(level_pos + 1..)?.trim_start();
616
617    // Split by ": " to get tag and message
618    after_level.find(": ").map_or_else(
619        || {
620            Some(LogcatParsed {
621                level,
622                tag: "unknown".to_string(),
623                message: after_level.to_string(),
624            })
625        },
626        |colon_pos| {
627            let tag = after_level[..colon_pos].trim();
628            let message = after_level[colon_pos + 2..].to_string();
629            Some(LogcatParsed {
630                level,
631                tag: tag.to_string(),
632                message,
633            })
634        },
635    )
636}
637
638/// Android emulator (AVD) that needs to be launched.
639///
640/// Unlike `AndroidDevice` which represents an already-connected device,
641/// `AndroidEmulator` represents an AVD that will be launched when `launch()` is called.
642#[derive(Debug)]
643pub struct AndroidEmulator {
644    /// AVD name.
645    avd_name: String,
646}
647
648impl AndroidEmulator {
649    /// Create a new Android emulator with the given AVD name.
650    #[must_use]
651    pub const fn new(avd_name: String) -> Self {
652        Self { avd_name }
653    }
654
655    /// Get the AVD name.
656    #[must_use]
657    pub fn avd_name(&self) -> &str {
658        &self.avd_name
659    }
660}
661
662impl Device for AndroidEmulator {
663    type Platform = AndroidPlatform;
664
665    async fn launch(&self) -> eyre::Result<()> {
666        let emulator_path =
667            AndroidSdk::emulator_path().ok_or_else(|| eyre::eyre!("Android emulator not found"))?;
668
669        // Start the emulator process (don't wait for it here, we'll poll for readiness)
670        Command::new(&emulator_path)
671            .arg("-avd")
672            .arg(&self.avd_name)
673            .arg("-no-snapshot-load")
674            .stdout(std::process::Stdio::null())
675            .stderr(std::process::Stdio::null())
676            .spawn()?;
677
678        // Wait for the emulator to boot by polling adb devices
679        let adb_path =
680            AndroidSdk::adb_path().ok_or_else(|| eyre::eyre!("Android adb not found"))?;
681
682        let start = std::time::Instant::now();
683        let timeout = std::time::Duration::from_secs(120);
684
685        loop {
686            if start.elapsed() > timeout {
687                eyre::bail!("Emulator launch timed out after 120 seconds");
688            }
689
690            // Check for booted emulator via adb
691            if let Ok(output) = Command::new(&adb_path).arg("devices").output().await {
692                if let Ok(stdout) = String::from_utf8(output.stdout) {
693                    for line in stdout.lines().skip(1) {
694                        let parts: Vec<&str> = line.split_whitespace().collect();
695                        if parts.len() >= 2
696                            && parts[0].starts_with("emulator-")
697                            && parts[1] == "device"
698                        {
699                            // Emulator is ready
700                            return Ok(());
701                        }
702                    }
703                }
704            }
705
706            smol::Timer::after(std::time::Duration::from_secs(2)).await;
707        }
708    }
709
710    fn platform(&self) -> Self::Platform {
711        // Default to arm64 for emulators - most common architecture
712        AndroidPlatform::arm64()
713    }
714
715    async fn run(&self, artifact: Artifact, options: RunOptions) -> Result<Running, FailToRun> {
716        let identifier = find_emulator_identifier().await?;
717        run_on_android(&identifier, artifact, options).await
718    }
719}
720
721#[cfg(test)]
722mod tests {
723    use super::{android_log_looks_like_crash, log_mentions_pid};
724
725    #[test]
726    fn detects_pid_mentions_in_threadtime_lines() {
727        let log = "12-10 23:04:40.190 28184 28184 F libc    : Fatal signal 11 (SIGSEGV)\n";
728        assert!(log_mentions_pid(log, 28184));
729        assert!(!log_mentions_pid(log, 12345));
730    }
731
732    #[test]
733    fn avoids_false_positive_from_unrelated_fatal_signal_in_global_dump() {
734        let unrelated = "12-10 23:04:40.190 999 999 F libc    : Fatal signal 11 (SIGSEGV)\n";
735        assert!(!android_log_looks_like_crash(
736            unrelated,
737            "com.example.app",
738            28184,
739            false
740        ));
741    }
742
743    #[test]
744    fn detects_native_crash_when_pid_is_mentioned() {
745        let log = "I DEBUG : Fatal signal 11 (SIGSEGV), code 1, fault addr 0x0 in tid 1 (main) pid: 28184\n";
746        assert!(android_log_looks_like_crash(
747            log,
748            "com.example.app",
749            28184,
750            false
751        ));
752    }
753
754    #[test]
755    fn detects_java_crash_for_app() {
756        let log = "E AndroidRuntime: FATAL EXCEPTION: main\nE AndroidRuntime: Process: com.example.app, PID: 28184\n";
757        assert!(android_log_looks_like_crash(
758            log,
759            "com.example.app",
760            28184,
761            false
762        ));
763    }
764}