use color_eyre::eyre::{self, eyre};
use smol::process::Command;
use tracing::error;
use std::process::Stdio;
use crate::{
android::{platform::AndroidPlatform, toolchain::AndroidSdk},
device::{Artifact, Device, DeviceEvent, FailToRun, LogLevel, RunOptions, Running},
utils::{parse_whitespace_separated_u32s, run_command, run_command_output},
};
#[derive(Debug)]
pub struct AndroidDevice {
identifier: String,
abi: String,
}
impl AndroidDevice {
#[must_use]
pub const fn new(identifier: String, abi: String) -> Self {
Self { identifier, abi }
}
#[must_use]
pub fn identifier(&self) -> &str {
&self.identifier
}
#[must_use]
pub fn abi(&self) -> &str {
&self.abi
}
}
impl Device for AndroidDevice {
type Platform = AndroidPlatform;
async fn launch(&self) -> eyre::Result<()> {
let adb = AndroidSdk::adb_path()
.ok_or_else(|| eyre::eyre!("Android SDK not found or adb not installed"))?;
run_command(
adb.to_str().unwrap(),
["-s", &self.identifier, "wait-for-device"],
)
.await?;
Ok(())
}
fn platform(&self) -> Self::Platform {
AndroidPlatform::from_abi(&self.abi)
}
async fn run(&self, artifact: Artifact, options: RunOptions) -> Result<Running, FailToRun> {
run_on_android(&self.identifier, artifact, options).await
}
}
#[allow(clippy::too_many_lines)]
async fn run_on_android(
device_id: &str,
artifact: Artifact,
options: RunOptions,
) -> Result<Running, FailToRun> {
let adb = AndroidSdk::adb_path()
.ok_or_else(|| FailToRun::Run(eyre!("Android SDK not found or adb not installed")))?;
let adb_str = adb.to_str().unwrap();
let env_vars: Vec<(String, String)> = options
.env_vars()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
let reverse_port = env_vars
.iter()
.find(|(k, _)| k == "WATERUI_HOT_RELOAD_PORT")
.and_then(|(_, v)| v.parse::<u16>().ok())
.zip(
env_vars
.iter()
.find(|(k, _)| k == "WATERUI_HOT_RELOAD_HOST")
.map(|(_, v)| v.as_str()),
)
.and_then(|(port, host)| {
if host == "127.0.0.1" || host == "localhost" {
Some(port)
} else {
None
}
});
if let Some(port) = reverse_port {
let spec = format!("tcp:{port}");
let output = Command::new(adb_str)
.args(["-s", device_id, "reverse", &spec, &spec])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await;
match output {
Ok(output) if output.status.success() => {}
Ok(output) => {
tracing::warn!(
"Failed to set up adb reverse for hot reload ({}): stdout='{}' stderr='{}'",
spec,
String::from_utf8_lossy(&output.stdout).trim(),
String::from_utf8_lossy(&output.stderr).trim()
);
}
Err(e) => {
tracing::warn!("Failed to set up adb reverse for hot reload ({spec}): {e}");
}
}
}
run_command(
adb_str,
[
"-s",
device_id,
"install",
"-r",
artifact.path().to_str().unwrap(),
],
)
.await
.map_err(|e| FailToRun::Install(eyre!("Failed to install APK: {e}")))?;
let mut start_args = vec![
"-s".to_string(),
device_id.to_string(),
"shell".to_string(),
"am".to_string(),
"start".to_string(),
"-S".to_string(), "-n".to_string(),
format!("{}/.MainActivity", artifact.bundle_id()),
];
for (key, value) in &env_vars {
start_args.push("--es".to_string());
start_args.push(format!("waterui.env.{key}"));
start_args.push(value.clone());
}
let output = Command::new(adb_str)
.args(&start_args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| FailToRun::Launch(eyre!("Failed to launch app: {e}")))?;
if !output.status.success() {
return Err(FailToRun::Launch(eyre!(
"Failed to launch app:\n{}\n{}",
String::from_utf8_lossy(&output.stdout).trim(),
String::from_utf8_lossy(&output.stderr).trim(),
)));
}
let pid = wait_for_app_pid(adb_str, device_id, artifact.bundle_id()).await?;
let adb_for_kill = adb.clone();
let identifier_for_kill = device_id.to_string();
let identifier_for_monitor = device_id.to_string();
let bundle_id_for_kill = artifact.bundle_id().to_string();
let bundle_id_for_monitor = artifact.bundle_id().to_string();
let log_level = options.log_level();
let reverse_port_for_drop = reverse_port;
let (running, sender) = Running::new(move || {
let result = std::process::Command::new(&adb_for_kill)
.args([
"-s",
&identifier_for_kill,
"shell",
"am",
"force-stop",
&bundle_id_for_kill,
])
.output();
match result {
Ok(output) => {
tracing::debug!(
"Force-stop command executed: status={}, stdout={}, stderr={}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
Err(e) => {
error!("Failed to stop app {}: {}", bundle_id_for_kill, e);
}
}
if let Some(port) = reverse_port_for_drop {
let spec = format!("tcp:{port}");
let _ = std::process::Command::new(&adb_for_kill)
.args(["-s", &identifier_for_kill, "reverse", "--remove", &spec])
.output();
}
});
let adb_for_monitor = adb.clone();
let sender_for_monitor = sender.clone();
smol::spawn(async move {
monitor_android_process(
adb_for_monitor,
&identifier_for_monitor,
&bundle_id_for_monitor,
pid,
sender_for_monitor,
)
.await;
})
.detach();
if let Some(level) = log_level {
let adb_for_logs = adb;
let identifier_for_logs = device_id.to_string();
smol::spawn(async move {
stream_android_logs(adb_for_logs, &identifier_for_logs, pid, level, sender).await;
})
.detach();
}
Ok(running)
}
async fn wait_for_app_pid(
adb_str: &str,
device_id: &str,
bundle_id: &str,
) -> Result<u32, FailToRun> {
for _ in 0..10 {
smol::Timer::after(std::time::Duration::from_millis(200)).await;
if let Ok(output) =
run_command(adb_str, ["-s", device_id, "shell", "pidof", bundle_id]).await
{
if let Some(pid) = parse_whitespace_separated_u32s(&output).into_iter().next() {
return Ok(pid);
}
}
}
let crash_info = run_command(
adb_str,
[
"-s",
device_id,
"logcat",
"-d",
"-t",
"100",
"-s",
"AndroidRuntime:E",
"DEBUG:*",
"WaterUI:*",
],
)
.await
.unwrap_or_default();
let mut error_msg = format!("App {bundle_id} crashed on startup (process not found).\n\n");
if !crash_info.trim().is_empty() {
error_msg.push_str("=== Crash Log ===\n");
error_msg.push_str(&crash_info);
}
Err(FailToRun::Launch(eyre!("{}", error_msg)))
}
async fn find_emulator_identifier() -> Result<String, FailToRun> {
let adb = AndroidSdk::adb_path()
.ok_or_else(|| FailToRun::Run(eyre!("Android SDK not found or adb not installed")))?;
let output = run_command(adb.to_str().unwrap(), ["devices"])
.await
.map_err(|e| FailToRun::Run(eyre!("Failed to list devices: {e}")))?;
output
.lines()
.skip(1)
.find_map(|line| {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 && parts[0].starts_with("emulator-") && parts[1] == "device" {
Some(parts[0].to_string())
} else {
None
}
})
.ok_or_else(|| FailToRun::Run(eyre!("Emulator not running")))
}
async fn monitor_android_process(
adb: std::path::PathBuf,
device_id: &str,
bundle_id: &str,
pid: u32,
sender: smol::channel::Sender<DeviceEvent>,
) {
let adb_str = adb.to_str().unwrap_or_default();
loop {
smol::Timer::after(std::time::Duration::from_secs(1)).await;
let result = run_command(adb_str, ["-s", device_id, "shell", "pidof", bundle_id]).await;
let still_running = result
.as_ref()
.ok()
.map(|output| parse_whitespace_separated_u32s(output))
.is_some_and(|pids| pids.contains(&pid));
if !still_running {
smol::Timer::after(std::time::Duration::from_millis(500)).await;
let pid_arg = format!("--pid={pid}");
let pid_log_args = vec![
"-s".to_string(),
device_id.to_string(),
"logcat".to_string(),
"-v".to_string(),
"threadtime".to_string(),
"-d".to_string(),
"-t".to_string(),
"200".to_string(),
pid_arg,
"*:V".to_string(),
];
let pid_log = run_command_output(adb_str, pid_log_args.iter().map(String::as_str))
.await
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default();
let fallback_log = if pid_log.trim().is_empty() {
let fallback_args = vec![
"-s".to_string(),
device_id.to_string(),
"logcat".to_string(),
"-v".to_string(),
"threadtime".to_string(),
"-d".to_string(),
"-t".to_string(),
"200".to_string(),
"-s".to_string(),
"AndroidRuntime:E".to_string(),
"DEBUG:*".to_string(),
"libc:F".to_string(),
];
run_command_output(adb_str, fallback_args.iter().map(String::as_str))
.await
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default()
} else {
String::new()
};
let pid_filtered = !pid_log.trim().is_empty();
let log_for_detection = if pid_filtered {
pid_log.as_str()
} else {
fallback_log.as_str()
};
if android_log_looks_like_crash(log_for_detection, bundle_id, pid, pid_filtered) {
let crash_log = if pid_log.trim().is_empty() {
fallback_log
} else {
pid_log
};
let error_msg = if crash_log.trim().is_empty() {
format!("Process {bundle_id} crashed.")
} else {
format!("Process {bundle_id} crashed.\n\n=== Crash Log ===\n{crash_log}")
};
let _ = sender.send(DeviceEvent::Crashed(error_msg)).await;
} else {
let _ = sender.send(DeviceEvent::Exited).await;
}
break;
}
}
}
fn log_mentions_pid(log: &str, pid: u32) -> bool {
let pid_str = pid.to_string();
let pid_lower = format!("pid: {pid}");
let pid_upper = format!("PID: {pid}");
log.lines().any(|line| {
line.split_whitespace().any(|part| part == pid_str)
|| line.contains(&pid_lower)
|| line.contains(&pid_upper)
})
}
fn android_log_looks_like_crash(log: &str, bundle_id: &str, pid: u32, pid_filtered: bool) -> bool {
if log.trim().is_empty() {
return false;
}
let relevant = pid_filtered || log.contains(bundle_id) || log_mentions_pid(log, pid);
if !relevant {
return false;
}
if log.contains("FATAL EXCEPTION") {
return true;
}
if log.contains("Fatal signal") {
return true;
}
if log.contains("SIGSEGV")
|| log.contains("SIGABRT")
|| log.contains("SIGBUS")
|| log.contains("SIGILL")
|| log.contains("SIGFPE")
{
return true;
}
if log.contains("Abort message:") || log.contains("backtrace:") {
return true;
}
if !log.contains(bundle_id) {
return false;
}
log.contains("AndroidRuntime")
&& (log.contains("E AndroidRuntime") || log.contains("Exception"))
}
async fn stream_android_logs(
adb: std::path::PathBuf,
device_id: &str,
pid: u32,
level: LogLevel,
sender: smol::channel::Sender<DeviceEvent>,
) {
use futures::StreamExt;
use futures::io::{AsyncBufReadExt, BufReader};
use smol::process::Command;
let priority = level.to_android_priority();
let pid_arg = format!("--pid={pid}");
let mut cmd = Command::new(&adb);
cmd.args(["-s", device_id, "logcat", "-v", "threadtime"])
.arg(pid_arg)
.arg(format!("*:{priority}"))
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null());
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
tracing::warn!("Failed to spawn logcat: {e}");
return;
}
};
let Some(stdout) = child.stdout.take() else {
return;
};
let reader = BufReader::new(stdout);
let mut lines = reader.lines();
while let Some(result) = lines.next().await {
let Ok(line) = result else { break };
let (parsed_level, message) = parse_logcat_line(&line);
let _ = sender
.send(DeviceEvent::Log {
level: parsed_level,
message,
})
.await;
}
let _ = child.kill();
}
struct LogcatParsed {
level: tracing::Level,
tag: String,
message: String,
}
fn parse_logcat_line(line: &str) -> (tracing::Level, String) {
if let Some(parsed) = try_parse_logcat(line) {
let formatted = format!("[{}] {}", parsed.tag, parsed.message);
return (parsed.level, formatted);
}
(tracing::Level::INFO, line.to_string())
}
fn try_parse_logcat(line: &str) -> Option<LogcatParsed> {
let parts: Vec<&str> = line.splitn(7, char::is_whitespace).collect();
if parts.len() < 6 {
return None;
}
let mut level_idx = None;
for (i, part) in parts.iter().enumerate() {
if part.len() == 1 {
let c = part.chars().next()?;
if matches!(c, 'V' | 'D' | 'I' | 'W' | 'E' | 'F') {
level_idx = Some(i);
break;
}
}
}
let level_idx = level_idx?;
if level_idx + 1 >= parts.len() {
return None;
}
let level = match parts[level_idx] {
"E" | "F" => tracing::Level::ERROR,
"W" => tracing::Level::WARN,
"D" => tracing::Level::DEBUG,
"V" => tracing::Level::TRACE,
_ => tracing::Level::INFO,
};
let level_char = parts[level_idx].chars().next()?;
let search_start = 18.min(line.len());
let level_pos = line[search_start..]
.find(level_char)
.map(|p| p + search_start)?;
let after_level = line.get(level_pos + 1..)?.trim_start();
after_level.find(": ").map_or_else(
|| {
Some(LogcatParsed {
level,
tag: "unknown".to_string(),
message: after_level.to_string(),
})
},
|colon_pos| {
let tag = after_level[..colon_pos].trim();
let message = after_level[colon_pos + 2..].to_string();
Some(LogcatParsed {
level,
tag: tag.to_string(),
message,
})
},
)
}
#[derive(Debug)]
pub struct AndroidEmulator {
avd_name: String,
}
impl AndroidEmulator {
#[must_use]
pub const fn new(avd_name: String) -> Self {
Self { avd_name }
}
#[must_use]
pub fn avd_name(&self) -> &str {
&self.avd_name
}
}
impl Device for AndroidEmulator {
type Platform = AndroidPlatform;
async fn launch(&self) -> eyre::Result<()> {
let emulator_path =
AndroidSdk::emulator_path().ok_or_else(|| eyre::eyre!("Android emulator not found"))?;
Command::new(&emulator_path)
.arg("-avd")
.arg(&self.avd_name)
.arg("-no-snapshot-load")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()?;
let adb_path =
AndroidSdk::adb_path().ok_or_else(|| eyre::eyre!("Android adb not found"))?;
let start = std::time::Instant::now();
let timeout = std::time::Duration::from_secs(120);
loop {
if start.elapsed() > timeout {
eyre::bail!("Emulator launch timed out after 120 seconds");
}
if let Ok(output) = Command::new(&adb_path).arg("devices").output().await {
if let Ok(stdout) = String::from_utf8(output.stdout) {
for line in stdout.lines().skip(1) {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2
&& parts[0].starts_with("emulator-")
&& parts[1] == "device"
{
return Ok(());
}
}
}
}
smol::Timer::after(std::time::Duration::from_secs(2)).await;
}
}
fn platform(&self) -> Self::Platform {
AndroidPlatform::arm64()
}
async fn run(&self, artifact: Artifact, options: RunOptions) -> Result<Running, FailToRun> {
let identifier = find_emulator_identifier().await?;
run_on_android(&identifier, artifact, options).await
}
}
#[cfg(test)]
mod tests {
use super::{android_log_looks_like_crash, log_mentions_pid};
#[test]
fn detects_pid_mentions_in_threadtime_lines() {
let log = "12-10 23:04:40.190 28184 28184 F libc : Fatal signal 11 (SIGSEGV)\n";
assert!(log_mentions_pid(log, 28184));
assert!(!log_mentions_pid(log, 12345));
}
#[test]
fn avoids_false_positive_from_unrelated_fatal_signal_in_global_dump() {
let unrelated = "12-10 23:04:40.190 999 999 F libc : Fatal signal 11 (SIGSEGV)\n";
assert!(!android_log_looks_like_crash(
unrelated,
"com.example.app",
28184,
false
));
}
#[test]
fn detects_native_crash_when_pid_is_mentioned() {
let log = "I DEBUG : Fatal signal 11 (SIGSEGV), code 1, fault addr 0x0 in tid 1 (main) pid: 28184\n";
assert!(android_log_looks_like_crash(
log,
"com.example.app",
28184,
false
));
}
#[test]
fn detects_java_crash_for_app() {
let log = "E AndroidRuntime: FATAL EXCEPTION: main\nE AndroidRuntime: Process: com.example.app, PID: 28184\n";
assert!(android_log_looks_like_crash(
log,
"com.example.app",
28184,
false
));
}
}