Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 33 additions & 22 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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
Expand Down
85 changes: 76 additions & 9 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
Expand All @@ -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<String> {
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<()> {
Expand Down Expand Up @@ -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");
}
Expand All @@ -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(());
Expand All @@ -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()))?;
Expand Down
Loading
Loading