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#[derive(Debug)]
15pub struct AndroidDevice {
16 identifier: String,
17 abi: String,
19}
20
21impl AndroidDevice {
22 #[must_use]
24 pub const fn new(identifier: String, abi: String) -> Self {
25 Self { identifier, abi }
26 }
27
28 #[must_use]
30 pub fn identifier(&self) -> &str {
31 &self.identifier
32 }
33
34 #[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#[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 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 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 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(), "-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 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 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 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 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
259async 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 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
305async 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
328async 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 loop {
340 smol::Timer::after(std::time::Duration::from_secs(1)).await;
341
342 let result = run_command(adb_str, ["-s", device_id, "shell", "pidof", bundle_id]).await;
346
347 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 smol::Timer::after(std::time::Duration::from_millis(500)).await;
357
358 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 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 let relevant = pid_filtered || log.contains(bundle_id) || log_mentions_pid(log, pid);
454 if !relevant {
455 return false;
456 }
457
458 if log.contains("FATAL EXCEPTION") {
460 return true;
461 }
462
463 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 !log.contains(bundle_id) {
482 return false;
483 }
484
485 log.contains("AndroidRuntime")
487 && (log.contains("E AndroidRuntime") || log.contains("Exception"))
488}
489
490async 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 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 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 let _ = child.kill();
546}
547
548struct LogcatParsed {
550 level: tracing::Level,
551 tag: String,
552 message: String,
553}
554
555fn parse_logcat_line(line: &str) -> (tracing::Level, String) {
558 if let Some(parsed) = try_parse_logcat(line) {
560 let formatted = format!("[{}] {}", parsed.tag, parsed.message);
561 return (parsed.level, formatted);
562 }
563
564 (tracing::Level::INFO, line.to_string())
566}
567
568fn try_parse_logcat(line: &str) -> Option<LogcatParsed> {
570 let parts: Vec<&str> = line.splitn(7, char::is_whitespace).collect();
575
576 if parts.len() < 6 {
578 return None;
579 }
580
581 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 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 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#[derive(Debug)]
643pub struct AndroidEmulator {
644 avd_name: String,
646}
647
648impl AndroidEmulator {
649 #[must_use]
651 pub const fn new(avd_name: String) -> Self {
652 Self { avd_name }
653 }
654
655 #[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 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 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 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 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 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}