diff --git a/Cargo.lock b/Cargo.lock index 1ebbc75..0c6d2ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,9 +64,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "cfg-if" @@ -76,9 +76,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "clap" -version = "4.6.1" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -86,9 +86,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -98,23 +98,23 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.6.5" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" +checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" dependencies = [ "clap", ] [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] @@ -175,15 +175,15 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" dependencies = [ "libc", ] @@ -202,18 +202,18 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -237,9 +237,20 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" dependencies = [ "proc-macro2", "quote", @@ -263,7 +274,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] diff --git a/README.md b/README.md index 3387d3b..ec8c67b 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,9 @@ On first `run`/`up`, `andro` provisions `~/.andro` end to end: 2. downloads the Android **command-line tools**, then installs `platform-tools`, `emulator` and the chosen **system image** (`google_apis` for phone, `android-tv` for `--tv`), -3. creates an AVD (`andro` or `andro-tv`), +3. creates an AVD (`andro` or `andro-tv`) — and recreates it when `--api` or + `--device` no longer match the one on disk (an AVD is disposable; only a + `--snapshot` quickboot state is lost), 4. boots the emulator (HVF), waits for `sys.boot_completed`, then waits until PackageManager actually answers (so the first install can't race the boot). @@ -175,6 +177,11 @@ go through the same `install-multiple` path. The splits are debug-signed by bund — fine for a disposable emulator. Override the bundletool download with `ANDRO_BUNDLETOOL_URL`. +andro drives one emulator at a time: if the *other* profile's AVD is up (say +`andro-tv` while you ask for a phone), `run`/`up` stop with an error instead of +installing on the wrong device — run `andro stop` first. `andro status` names +the running AVD (`running_avd` in `--json`). + Everything is contained in `~/.andro`, so cleanup is just removing that folder. ## Known limitations diff --git a/src/commands.rs b/src/commands.rs index 4232718..ed1722f 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -56,6 +56,10 @@ pub fn run( let pkg = emulator::newly_installed(&before, &after) .into_iter() .next() + // Reinstalling an app the device already has adds nothing to the package + // list, so fall back to whichever third-party package was just written + // rather than to an arbitrary one. + .or_else(|| just_updated_package(&s, &after)) .or_else(|| after.last().cloned()) .context("could not determine the installed package")?; println!("✅ installed {pkg}"); @@ -71,6 +75,13 @@ pub fn run( Ok(()) } +/// The third-party package with the freshest `lastUpdateTime`, i.e. the one the +/// install just refreshed. Best-effort: `None` if dumpsys can't be read. +fn just_updated_package(s: &Sdk, candidates: &[String]) -> Option { + let out = s.adb_try(&["shell", "dumpsys", "package", "packages"])?; + emulator::most_recently_updated(&out, candidates) +} + /// Install whatever `target` is — single apk, split-apk directory, bundle zip, or /// `.aab` (converted to device-matched splits with bundletool first). fn install_target(s: &Sdk, target: &Path, downgrade: bool) -> Result<()> { @@ -515,16 +526,29 @@ pub fn status(cfg: &Config, json: bool) -> Result<()> { let s = sdk(cfg); let running = provision::is_running(&s); let booted = running && provision::is_booted(&s); + // Which AVD is actually up — not necessarily the one this profile asks for. + let running_avd = if running { + provision::running_avd_name(&s) + } else { + None + }; if json { + let running_avd_json = running_avd + .as_deref() + .map(|n| format!("\"{n}\"")) + .unwrap_or_else(|| "null".to_string()); println!( - "{{\"running\":{running},\"booted\":{booted},\"avd\":\"{}\",\"profile\":\"{:?}\",\"api\":{},\"adb_port\":{}}}", + "{{\"running\":{running},\"booted\":{booted},\"avd\":\"{}\",\"running_avd\":{running_avd_json},\"profile\":\"{:?}\",\"api\":{},\"adb_port\":{}}}", cfg.profile.avd_name(), cfg.profile, cfg.api, crate::sdk::ADB_SERVER_PORT ); } else if running { - println!("emulator: running (booted={booted})"); + match running_avd.as_deref() { + Some(avd) => println!("emulator: running (avd={avd}, booted={booted})"), + None => println!("emulator: running (booted={booted})"), + } } else { println!("emulator: not running"); } @@ -540,21 +564,60 @@ pub fn status(cfg: &Config, json: bool) -> Result<()> { } /// `andro stop` — stop the emulator, keep `~/.andro`. +/// +/// `adb emu kill` is fire-and-forget and does not always take: a kill sent while +/// the emulator is still early in its boot is acknowledged and ignored, and the +/// old code announced "emulator stopped" either way, leaving a live emulator +/// behind an exit code of 0. Confirm the device really goes away, ask a second +/// time if it hasn't, and fail loudly rather than lie. pub fn stop(cfg: &Config) -> Result<()> { let s = sdk(cfg); - if provision::is_running(&s) { - let _ = s.adb_try(&["emu", "kill"]); - println!("⏹ emulator stopped"); - } else { + if !provision::is_running(&s) { println!("emulator not running"); + return Ok(()); } + kill_emulator(&s)?; + println!("⏹ emulator stopped"); Ok(()) } +/// How long a single `emu kill` gets to actually detach the device. +const STOP_TIMEOUT: Duration = Duration::from_secs(20); + +/// Kill the running emulator and wait for it to actually detach, asking a second +/// time if the first request was ignored. Errors if it is still there after that. +fn kill_emulator(s: &Sdk) -> Result<()> { + for attempt in 0..2 { + let _ = s.adb_try(&["emu", "kill"]); + if wait_until_gone(s, STOP_TIMEOUT) { + return Ok(()); + } + if attempt == 0 { + eprintln!("… emulator still attached, asking again"); + } + } + bail!("emulator did not stop — run `andro status`, then try again"); +} + +/// Poll until no emulator is attached any more. True if it went away in time. +fn wait_until_gone(s: &Sdk, timeout: Duration) -> bool { + let start = std::time::Instant::now(); + loop { + if !provision::is_running(s) { + return true; + } + if start.elapsed() > timeout { + return false; + } + std::thread::sleep(Duration::from_secs(1)); + } +} + /// `andro clean` — kill the emulator and remove `~/.andro`. pub fn clean(cfg: &Config, yes: bool) -> Result<()> { - // Check existence BEFORE any adb call: `Sdk::command` creates the contained - // dirs, which would otherwise resurrect `~/.andro` and defeat this no-op. + // Check existence BEFORE any adb call, and keep `Sdk::command` from creating + // its contained dirs under a missing home (see `Sdk::command`) — together + // that is what makes "nothing left behind" hold for later commands too. if !cfg.home.exists() { println!("nothing to clean ({} does not exist)", cfg.home.display()); return Ok(()); @@ -568,7 +631,11 @@ pub fn clean(cfg: &Config, yes: bool) -> Result<()> { } let s = sdk(cfg); // These target andro's dedicated adb port, not the user's global 5037 server. - let _ = s.adb_try(&["emu", "kill"]); + // Confirm the emulator is really gone before deleting the tree: unlinking it + // under a live emulator leaves a process running with no adb left to kill it. + if provision::is_running(&s) { + kill_emulator(&s)?; + } let _ = s.adb_try(&["kill-server"]); fs::remove_dir_all(&cfg.home) .with_context(|| format!("failed to remove {}", cfg.home.display()))?; diff --git a/src/emulator.rs b/src/emulator.rs index 182c4ca..e9d62fc 100644 --- a/src/emulator.rs +++ b/src/emulator.rs @@ -121,6 +121,97 @@ pub fn parse_image_sysdir(config_ini: &str) -> Option { .map(|v| v.trim().trim_end_matches('/').to_string()) } +/// Read a `key=value` out of a `config.ini`, trimmed. `None` when absent. +pub fn config_value(config_ini: &str, key: &str) -> Option { + let prefix = format!("{key}="); + config_ini + .lines() + .find_map(|l| l.trim().strip_prefix(&prefix)) + .map(|v| v.trim().to_string()) +} + +/// True when an existing AVD's `config.ini` still matches the requested system +/// image and device profile. +/// +/// `expected_sysdir` is the image in path form (`system-images/android-36/...`, +/// i.e. `Config::image()` with `;` replaced by `/`) and `expected_device` the +/// avdmanager device name. A `config.ini` missing either key counts as a +/// mismatch: we cannot prove it matches, and an AVD is disposable by design. +pub fn avd_matches(config_ini: &str, expected_sysdir: &str, expected_device: &str) -> bool { + let sysdir = parse_image_sysdir(config_ini); + let device = config_value(config_ini, "hw.device.name"); + match (sysdir, device) { + (Some(s), Some(d)) => s == expected_sysdir.trim_end_matches('/') && d == expected_device, + _ => false, + } +} + +/// What an existing AVD is, in the same shape [`avd_label`] renders a request: +/// `android-34/google_apis/arm64-v8a on pixel`. Lets a recreate or refusal +/// message name exactly what changed — API level, image tag or device. +pub fn avd_label_from_config(config_ini: &str) -> String { + let image = parse_image_sysdir(config_ini).unwrap_or_else(|| "unknown".to_string()); + let device = config_value(config_ini, "hw.device.name").unwrap_or_else(|| "unknown".into()); + avd_label(&image, &device) +} + +/// The same label for a *requested* configuration. `sysdir` is the image in path +/// form (`Config::image()` with `;` replaced by `/`); the `system-images/` prefix +/// is dropped so both sides read as `android-36/google_apis/arm64-v8a on pixel`. +pub fn avd_label(sysdir: &str, device: &str) -> String { + let image = sysdir + .trim_end_matches('/') + .strip_prefix("system-images/") + .unwrap_or(sysdir.trim_end_matches('/')); + format!("{image} on {device}") +} + +/// The candidate package with the most recent `lastUpdateTime` in +/// `adb shell dumpsys package packages` output. +/// +/// Reinstalling an app the emulator already has adds nothing to `pm list +/// packages`, so the install/launch path has no new package to point at. This +/// finds the one that was just written instead of guessing. `candidates` is the +/// third-party package list; anything outside it is ignored. `lastUpdateTime` +/// is `YYYY-MM-DD HH:MM:SS`, which compares correctly as a string. +pub fn most_recently_updated(dumpsys_packages: &str, candidates: &[String]) -> Option { + let mut current: Option<&str> = None; + let mut best: Option<(String, String)> = None; + for line in dumpsys_packages.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("Package [") { + current = rest.split(']').next(); + } else if let Some(value) = line.strip_prefix("lastUpdateTime=") + && let Some(pkg) = current + && candidates.iter().any(|c| c == pkg) + { + let when = value.trim().to_string(); + if best.as_ref().is_none_or(|(best_when, _)| when > *best_when) { + best = Some((when, pkg.to_string())); + } + } + } + best.map(|(_, pkg)| pkg) +} + +/// Parse `adb emu avd name`, whose reply is the AVD name followed by adb's +/// trailing `OK`. `None` when nothing is attached (`error: no emulator +/// detected`) or the console answered anything but a name. +pub fn parse_avd_name(output: &str) -> Option { + output + .lines() + .map(str::trim) + .find(|l| !l.is_empty()) + .filter(|l| { + *l != "OK" + && *l != "KO" + && !l.starts_with("error") + && !l.starts_with("KO:") + && !l.contains(':') + }) + .map(str::to_string) +} + /// Ensure an AVD `config.ini` enables the emulated hardware keyboard so the host /// (Mac) keyboard types into the guest. avdmanager's device profiles default /// `hw.keyboard=no`, which silently drops host key events — you tap a field, the @@ -230,6 +321,147 @@ mod tests { assert_eq!(parse_image_sysdir(""), None); } + const AVD_INI: &str = "avd.ini.displayname=andro\n\ + abi.type=arm64-v8a\n\ + hw.device.name=pixel\n\ + image.sysdir.1=system-images/android-36/google_apis/arm64-v8a/\n\ + tag.id=google_apis\n"; + + #[test] + fn avd_matches_when_image_and_device_agree() { + assert!(avd_matches( + AVD_INI, + "system-images/android-36/google_apis/arm64-v8a", + "pixel" + )); + } + + #[test] + fn avd_matches_false_on_different_api_or_device() { + // a different API level (the common `--api 34` -> `--api 36` case) + assert!(!avd_matches( + AVD_INI, + "system-images/android-34/google_apis/arm64-v8a", + "pixel" + )); + // a different device profile + assert!(!avd_matches( + AVD_INI, + "system-images/android-36/google_apis/arm64-v8a", + "pixel_7" + )); + // a different tag (phone -> playstore image) + assert!(!avd_matches( + AVD_INI, + "system-images/android-36/google_apis_playstore/arm64-v8a", + "pixel" + )); + } + + #[test] + fn avd_matches_false_when_keys_are_missing() { + assert!(!avd_matches( + "hw.ramSize=2048\n", + "system-images/x", + "pixel" + )); + assert!(!avd_matches( + "image.sysdir.1=system-images/x\n", + "system-images/x", + "pixel" + )); + assert!(!avd_matches( + "hw.device.name=pixel\n", + "system-images/x", + "pixel" + )); + assert!(!avd_matches("", "system-images/x", "pixel")); + } + + const DUMPSYS: &str = "\ +Packages: + Package [com.android.settings] (a1b2c3): + userId=1000 + firstInstallTime=2026-01-01 09:00:00 + lastUpdateTime=2026-09-04 18:00:00 + Package [com.old.app] (d4e5f6): + userId=10201 + firstInstallTime=2026-02-02 10:00:00 + lastUpdateTime=2026-02-02 10:00:00 + Package [com.fresh.app] (7a8b9c): + userId=10202 + firstInstallTime=2026-03-03 11:00:00 + lastUpdateTime=2026-09-04 17:59:12 +"; + + #[test] + fn most_recently_updated_picks_the_latest_candidate() { + let candidates = vec!["com.old.app".to_string(), "com.fresh.app".to_string()]; + assert_eq!( + most_recently_updated(DUMPSYS, &candidates), + Some("com.fresh.app".to_string()) + ); + } + + #[test] + fn most_recently_updated_ignores_packages_outside_candidates() { + // com.android.settings has the newest timestamp but is not third-party. + let candidates = vec!["com.old.app".to_string()]; + assert_eq!( + most_recently_updated(DUMPSYS, &candidates), + Some("com.old.app".to_string()) + ); + } + + #[test] + fn most_recently_updated_none_without_a_match() { + assert_eq!(most_recently_updated("", &["com.a".to_string()]), None); + assert_eq!(most_recently_updated(DUMPSYS, &[]), None); + assert_eq!( + most_recently_updated(DUMPSYS, &["com.absent.app".to_string()]), + None + ); + } + + #[test] + fn parse_avd_name_reads_the_console_reply() { + assert_eq!( + parse_avd_name("andro-tv\r\nOK\r\n"), + Some("andro-tv".to_string()) + ); + assert_eq!(parse_avd_name("andro\nOK\n"), Some("andro".to_string())); + assert_eq!(parse_avd_name("\n\nandro\nOK\n"), Some("andro".to_string())); + } + + #[test] + fn parse_avd_name_none_when_no_emulator() { + assert_eq!(parse_avd_name("error: no emulator detected"), None); + assert_eq!(parse_avd_name("KO: unknown command\r\n"), None); + assert_eq!(parse_avd_name("OK\r\n"), None); + assert_eq!(parse_avd_name(""), None); + } + + #[test] + fn avd_label_names_image_and_device_on_both_sides() { + assert_eq!( + avd_label_from_config(AVD_INI), + "android-36/google_apis/arm64-v8a on pixel" + ); + // a requested config renders identically, so a message reads as a diff + assert_eq!( + avd_label("system-images/android-34/google_apis/arm64-v8a", "pixel"), + "android-34/google_apis/arm64-v8a on pixel" + ); + } + + #[test] + fn avd_label_from_config_says_unknown_when_unreadable() { + assert_eq!( + avd_label_from_config("hw.ramSize=2048\n"), + "unknown on unknown" + ); + } + #[test] fn hw_keyboard_flips_no_to_yes_and_leaves_siblings() { let ini = "hw.dPad=no\n\ diff --git a/src/provision.rs b/src/provision.rs index bef66b3..2e5afe8 100644 --- a/src/provision.rs +++ b/src/provision.rs @@ -5,7 +5,7 @@ use std::fs; use std::io::Write; use std::path::Path; -use std::process::{Command, Stdio}; +use std::process::{Child, Command, Stdio}; use std::thread::sleep; use std::time::{Duration, Instant}; @@ -38,6 +38,7 @@ fn download(url: &str, dest: &Path) -> Result<()> { /// Run the whole chain: JDK → SDK → AVD → boot. pub fn up(sdk: &Sdk, cfg: &Config) -> Result<()> { + check_running_profile(sdk, cfg)?; ensure_jdk(sdk, std::env::consts::ARCH)?; ensure_sdk(sdk, &cfg.image())?; ensure_avd(sdk, cfg)?; @@ -130,13 +131,55 @@ fn accept_licenses(sdk: &Sdk) { } } +/// Create the AVD if missing, or recreate it when `--api`/`--device`/the image +/// tag no longer match what it was created with. +/// +/// Existence alone used to be the whole check, so changing `--api` silently did +/// nothing: `andro --api 36 run app.apk` kept booting the android-34 AVD created +/// by an earlier run. An AVD is disposable by design (the SDK image it points at +/// is not re-downloaded), so recreating it is the right answer — only a +/// `--snapshot` quickboot state is lost, which the message says out loud. pub fn ensure_avd(sdk: &Sdk, cfg: &Config) -> Result<()> { - let avd_ini = sdk - .avd_home() - .join(format!("{}.ini", cfg.profile.avd_name())); + let name = cfg.profile.avd_name(); + let avd_ini = sdk.avd_home().join(format!("{name}.ini")); if avd_ini.exists() { - return Ok(()); + let config_ini = sdk + .avd_home() + .join(format!("{name}.avd")) + .join("config.ini"); + let text = fs::read_to_string(&config_ini).unwrap_or_default(); + let want_sysdir = cfg.image().replace(';', "/"); + if emulator::avd_matches(&text, &want_sysdir, &cfg.device) { + return Ok(()); + } + let had = emulator::avd_label_from_config(&text); + let want = emulator::avd_label(&want_sysdir, &cfg.device); + if is_running(sdk) { + bail!( + "AVD '{name}' is {had} but {want} was requested — \ + run `andro stop` first so it can be recreated" + ); + } + eprintln!("♻️ recreating AVD '{name}' ({had} → {want})…"); + delete_avd(sdk, name)?; } + create_avd(sdk, cfg) +} + +/// `avdmanager delete avd -n `. Also sweeps the `.avd` dir and `.ini` in +/// case avdmanager left residue, so the following create starts from nothing. +fn delete_avd(sdk: &Sdk, name: &str) -> Result<()> { + let mut c = sdk.command(&sdk.avdmanager()); + c.args(["delete", "avd", "-n", name]) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let _ = run_checked(c, "avdmanager delete avd"); + let _ = fs::remove_dir_all(sdk.avd_home().join(format!("{name}.avd"))); + let _ = fs::remove_file(sdk.avd_home().join(format!("{name}.ini"))); + Ok(()) +} + +fn create_avd(sdk: &Sdk, cfg: &Config) -> Result<()> { fs::create_dir_all(sdk.avd_home())?; eprintln!("🛠 creating AVD '{}'…", cfg.profile.avd_name()); let mut child = sdk @@ -186,10 +229,40 @@ pub fn is_booted(sdk: &Sdk) -> bool { .unwrap_or(false) } +/// Name of the AVD currently attached to andro's adb server, if any. +/// Asks the emulator console directly (`adb emu avd name`). +pub fn running_avd_name(sdk: &Sdk) -> Option { + sdk.adb_try(&["emu", "avd", "name"]) + .as_deref() + .and_then(emulator::parse_avd_name) +} + +/// Refuse to drive an emulator that belongs to the other profile. +/// +/// `boot` short-circuits on `is_booted`, which only asks "is *something* +/// booted?". With `andro-tv` up, a plain `andro run app.apk` would happily +/// install and launch the phone app on the TV emulator. andro runs one emulator +/// at a time, so the honest answer is to stop first. +fn check_running_profile(sdk: &Sdk, cfg: &Config) -> Result<()> { + let want = cfg.profile.avd_name(); + if let Some(running) = running_avd_name(sdk) + && running != want + { + bail!( + "emulator '{running}' is running but profile {:?} (AVD '{want}') was requested — \ + run `andro stop` first", + cfg.profile + ); + } + Ok(()) +} + pub fn boot(sdk: &Sdk, cfg: &Config) -> Result<()> { + check_running_profile(sdk, cfg)?; if is_booted(sdk) { return Ok(()); } + let mut child: Option = None; if !is_running(sdk) { clear_stale_locks(sdk, cfg); enable_hw_keyboard(sdk, cfg); @@ -202,9 +275,11 @@ pub fn boot(sdk: &Sdk, cfg: &Config) -> Result<()> { { c.stdout(Stdio::from(f)).stderr(Stdio::from(f2)); } - c.spawn().context("failed to launch emulator")?; // detached + // Detached, but keep the handle: watching it lets us fail fast (with the + // emulator log) instead of polling a dead process until BOOT_TIMEOUT. + child = Some(c.spawn().context("failed to launch emulator")?); } - wait_for_boot(sdk) + wait_ready_with(sdk, BOOT_TIMEOUT, false, child.as_mut()) } /// Emulator launch flags. Cold boot by default (`-no-snapshot`) to keep a clean @@ -265,16 +340,26 @@ fn clear_stale_locks(sdk: &Sdk, cfg: &Config) { } } -fn wait_for_boot(sdk: &Sdk) -> Result<()> { - wait_ready(sdk, BOOT_TIMEOUT, false) +/// Block until the emulator is ready: `sys.boot_completed` → (unless `boot_only`) +/// PackageManager answers. Errors on timeout. Does NOT boot — waits on whatever +/// is currently running. Exposed for `andro wait`. +pub fn wait_ready(sdk: &Sdk, timeout: Duration, boot_only: bool) -> Result<()> { + wait_ready_with(sdk, timeout, boot_only, None) } -/// Block until the emulator is ready: device attached → `sys.boot_completed` → -/// (unless `boot_only`) PackageManager answers. Errors on timeout. Does NOT -/// boot — waits on whatever is currently running. Exposed for `andro wait`. -pub fn wait_ready(sdk: &Sdk, timeout: Duration, boot_only: bool) -> Result<()> { +/// The polling loop behind [`wait_ready`], optionally watching the emulator +/// process we just spawned. +/// +/// No `adb wait-for-device` here: that call blocks forever when nothing is +/// attached, which silently swallowed the timeout. `adb shell getprop` fails +/// immediately without a device, so plain polling honours the deadline. +fn wait_ready_with( + sdk: &Sdk, + timeout: Duration, + boot_only: bool, + mut child: Option<&mut Child>, +) -> Result<()> { let start = Instant::now(); - let _ = sdk.adb_try(&["wait-for-device"]); loop { if is_booted(sdk) { eprintln!("✅ emulator booted"); @@ -283,6 +368,22 @@ pub fn wait_ready(sdk: &Sdk, timeout: Duration, boot_only: bool) -> Result<()> { } return Ok(()); } + // A crashed emulator (bad AVD, missing image, KVM/HVF trouble) would + // otherwise keep us polling for the full timeout. Only a non-zero exit + // is fatal: a launcher that hands off and exits 0 stays a normal boot. + if let Some(c) = child.as_deref_mut() + && let Ok(Some(status)) = c.try_wait() + { + if !status.success() { + bail!( + "emulator exited early (status {:?}) — last lines of {}:\n{}", + status.code(), + sdk.home().join("emulator.log").display(), + emulator_log_tail(sdk) + ); + } + child = None; + } if start.elapsed() > timeout { bail!( "emulator did not become ready within {}s", @@ -293,6 +394,23 @@ pub fn wait_ready(sdk: &Sdk, timeout: Duration, boot_only: bool) -> Result<()> { } } +/// Last few lines of `~/.andro/emulator.log`, for a crash message that says why. +fn emulator_log_tail(sdk: &Sdk) -> String { + const LINES: usize = 20; + match fs::read_to_string(sdk.home().join("emulator.log")) { + Ok(text) => { + let all: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect(); + let tail = &all[all.len().saturating_sub(LINES)..]; + if tail.is_empty() { + "(log is empty)".to_string() + } else { + tail.join("\n") + } + } + Err(_) => "(no emulator log)".to_string(), + } +} + /// `sys.boot_completed=1` fires before PackageManager is fully up, so installing /// right after boot can flake. Poll until `cmd package` answers. Bounded and /// best-effort: on timeout we fall through rather than regress a good boot. diff --git a/src/sdk.rs b/src/sdk.rs index b90c4b4..ba39e86 100644 --- a/src/sdk.rs +++ b/src/sdk.rs @@ -169,11 +169,17 @@ impl Sdk { /// the bundled adb honours for its key dir), the emulator's `.android` home /// is redirected, a dedicated adb port isolates our server, and temp dirs are /// contained. The dirs are created here so the first adb/emulator call can't - /// fail trying to `mkdir` a missing `.android` parent. + /// fail trying to `mkdir` a missing `.android` parent — but only once the home + /// itself exists, so a read-only command after `clean` (`status`, `stop`) + /// cannot resurrect the tree we just promised to leave nothing behind. A + /// missing home has no binaries to run anyway; provisioning creates it in + /// `ensure_jdk` before the first tool call. pub fn command(&self, program: &Path) -> Command { - let _ = std::fs::create_dir_all(self.android_home()); - let _ = std::fs::create_dir_all(self.tmp_dir()); - let _ = std::fs::create_dir_all(self.avd_home()); + if self.home.is_dir() { + let _ = std::fs::create_dir_all(self.android_home()); + let _ = std::fs::create_dir_all(self.tmp_dir()); + let _ = std::fs::create_dir_all(self.avd_home()); + } let mut c = Command::new(program); let path = format!( "{}:{}:{}", diff --git a/tests/cli.rs b/tests/cli.rs index 7f74041..b7312f0 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -76,13 +76,53 @@ fn autoclean_dry_run_on_missing_home_is_noop() { assert!(String::from_utf8_lossy(&out.stdout).contains("nothing to clean")); } +/// A read-only command on a missing home must exit 3 *and* leave the filesystem +/// alone: `Sdk::command` used to `create_dir_all` unconditionally, so `status` +/// or `stop` right after `clean` recreated `~/.andro/{avd,home,tmp}`. #[test] fn status_exits_3_when_not_running() { + let home = std::env::temp_dir().join("andro-status-none-xyz"); + let _ = std::fs::remove_dir_all(&home); let out = Command::new(BIN) - .args(["--home", "/tmp/andro-status-none-xyz", "status"]) + .args(["--home"]) + .arg(&home) + .arg("status") .output() .expect("run andro"); assert_eq!(out.status.code(), Some(3)); + assert!( + !home.exists(), + "status must not recreate {}", + home.display() + ); +} + +/// `wait --timeout N` must give up after N seconds. It used to call +/// `adb wait-for-device`, which blocks forever when no device is attached, so +/// the timeout never fired and `up` hung on a crashed emulator. +#[test] +fn wait_times_out_when_nothing_runs() { + let home = std::env::temp_dir().join("andro-wait-timeout-xyz"); + let _ = std::fs::remove_dir_all(&home); + let start = std::time::Instant::now(); + let out = Command::new(BIN) + .args(["--home"]) + .arg(&home) + .args(["wait", "--timeout", "1"]) + .output() + .expect("run andro"); + let elapsed = start.elapsed(); + assert!(!out.status.success(), "wait should fail with no emulator"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("did not become ready"), + "expected a timeout message; got:\n{stderr}" + ); + assert!( + elapsed < std::time::Duration::from_secs(30), + "wait --timeout 1 took {elapsed:?} — the timeout is not honoured" + ); + let _ = std::fs::remove_dir_all(&home); } #[test]