use crate::units::ImageRef;
use anyhow::{Result, bail};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct Sdk {
pub root: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SystemImage {
pub api: u32,
pub minor: Option<u32>,
pub flavour: String,
pub abi: String,
pub sysdir: String,
pub package: String,
}
pub fn host_abi() -> &'static str {
if cfg!(target_arch = "aarch64") {
"arm64-v8a"
} else {
"x86_64"
}
}
impl Sdk {
pub fn locate() -> Result<Self> {
let mut candidates: Vec<PathBuf> = Vec::new();
for var in ["ANDROID_HOME", "ANDROID_SDK_ROOT"] {
if let Some(v) = std::env::var_os(var).filter(|v| !v.is_empty()) {
candidates.push(v.into());
}
}
if let Some(home) = dirs_home() {
candidates.push(home.join("Library/Android/sdk"));
candidates.push(home.join("Android/Sdk"));
}
for c in &candidates {
if c.join("platform-tools").is_dir() || c.join("emulator").is_dir() {
crate::log::vlog!(
"android sdk: {} (checked {} candidate paths)",
c.display(),
candidates.len()
);
return Ok(Self { root: c.clone() });
}
}
bail!(
"Android SDK not found. Set ANDROID_HOME, or install the command-line tools:\n \
brew install --cask android-commandlinetools"
)
}
pub fn emulator(&self) -> PathBuf {
self.root.join("emulator").join("emulator")
}
pub fn adb(&self) -> PathBuf {
self.root.join("platform-tools").join("adb")
}
pub fn sdkmanager(&self) -> Option<PathBuf> {
let base = self.root.join("cmdline-tools");
let mut dirs = read_dirs(&base);
dirs.sort_by_key(|d| std::cmp::Reverse(file_name(d) == "latest"));
dirs.into_iter()
.map(|d| d.join("bin").join("sdkmanager"))
.find(|p| p.is_file())
}
pub fn emulator_version(&self) -> Result<String> {
let out = std::process::Command::new(self.emulator())
.arg("-version")
.output()?;
let text = String::from_utf8_lossy(&out.stdout);
text.lines()
.find_map(|l| l.split("emulator version ").nth(1))
.and_then(|v| v.split_whitespace().next())
.map(str::to_string)
.ok_or_else(|| {
anyhow::anyhow!("could not read the emulator version from `emulator -version`")
})
}
pub fn system_image_revision(&self, image: &SystemImage) -> Result<String> {
let xml = std::fs::read_to_string(self.root.join(&image.sysdir).join("package.xml"))?;
parse_revision(&xml)
.ok_or_else(|| anyhow::anyhow!("no <revision> in package.xml for {}", image.package))
}
pub fn system_images(&self) -> Vec<SystemImage> {
let base = self.root.join("system-images");
let mut out = Vec::new();
for api_dir in read_dirs(&base) {
let name = file_name(&api_dir);
let Some((api, minor)) = name.strip_prefix("android-").and_then(parse_api) else {
continue;
};
for flavour_dir in read_dirs(&api_dir) {
for abi_dir in read_dirs(&flavour_dir) {
if !abi_dir.join("system.img").is_file() {
continue;
}
let flavour = file_name(&flavour_dir);
let abi = file_name(&abi_dir);
out.push(SystemImage {
api,
minor,
sysdir: format!("system-images/{name}/{flavour}/{abi}/"),
package: format!("system-images;{name};{flavour};{abi}"),
flavour,
abi,
});
}
}
}
out.sort_by(|a, b| {
b.api
.cmp(&a.api)
.then(a.flavour.cmp(&b.flavour))
.then(a.abi.cmp(&b.abi))
});
out
}
pub fn resolve(&self, image: &ImageRef) -> Result<SystemImage> {
let abi = host_abi();
let installed = self.system_images();
let matching: Vec<&SystemImage> = installed
.iter()
.filter(|i| i.api == image.api && i.abi == abi)
.filter(|i| image.minor.is_none() || i.minor == image.minor)
.filter(|i| image.flavour.as_ref().is_none_or(|f| &i.flavour == f))
.collect();
let mut matching = matching;
matching.sort_by_key(|i| i.minor.is_some());
const PREFERENCE: [&str; 3] = ["google_apis", "default", "aosp_atd"];
for pref in PREFERENCE {
if let Some(i) = matching.iter().find(|i| i.flavour == pref) {
return Ok((*i).clone());
}
}
if let Some(i) = matching.first() {
return Ok((*i).clone());
}
let wanted = package_for(image);
let shown = version_label(image.api, image.minor);
let have: Vec<String> = installed.iter().map(|i| i.package.clone()).collect();
bail!(
"no installed Android API {shown} system image for {abi}\n\nRun:\n dorker pull android:{shown}\n(or: sdkmanager \"{wanted}\")\n\n\
Installed images: {}",
if have.is_empty() {
"none".into()
} else {
have.join(", ")
}
)
}
}
pub fn package_for(image: &ImageRef) -> String {
format!(
"system-images;android-{};{};{}",
version_label(image.api, image.minor),
image.flavour.as_deref().unwrap_or("google_apis"),
host_abi()
)
}
pub fn version_label(api: u32, minor: Option<u32>) -> String {
match minor {
Some(m) => format!("{api}.{m}"),
None => api.to_string(),
}
}
impl Sdk {
pub fn find_package(&self, package: &str) -> Option<SystemImage> {
self.system_images()
.into_iter()
.find(|i| i.package == package)
}
}
pub fn parse_revision(xml: &str) -> Option<String> {
let start = xml.find("<revision>")?;
let block = &xml[start..start + xml[start..].find("</revision>")?];
let part = |tag: &str| -> Option<String> {
let open = format!("<{tag}>");
let from = block.find(&open)? + open.len();
let len = block[from..].find(&format!("</{tag}>"))?;
Some(block[from..from + len].trim().to_string())
};
let major = part("major")?;
Some(format!(
"{major}.{}.{}",
part("minor").unwrap_or_else(|| "0".into()),
part("micro").unwrap_or_else(|| "0".into())
))
}
fn parse_api(s: &str) -> Option<(u32, Option<u32>)> {
match s.split_once('.') {
Some((a, b)) => Some((a.parse().ok()?, Some(b.parse().ok()?))),
None => Some((s.parse().ok()?, None)),
}
}
fn read_dirs(p: &Path) -> Vec<PathBuf> {
let mut v: Vec<PathBuf> = std::fs::read_dir(p)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
v.sort();
v
}
fn file_name(p: &Path) -> String {
p.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
}
pub fn dirs_home() -> Option<PathBuf> {
std::env::var_os("HOME").map(PathBuf::from)
}
#[cfg(test)]
mod tests {
use super::*;
fn fake_sdk(layout: &[(&str, &str, &str)]) -> (crate::testutil::Dir, Sdk) {
let dir = crate::testutil::Dir::new();
for (api, flavour, abi) in layout {
let d = dir
.path()
.join(format!("system-images/android-{api}/{flavour}/{abi}"));
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("system.img"), b"x").unwrap();
}
let sdk = Sdk {
root: dir.path().to_path_buf(),
};
(dir, sdk)
}
#[test]
fn resolves_preferred_flavour() {
let abi = host_abi();
let (_d, sdk) = fake_sdk(&[
("35", "google_apis_playstore", abi),
("35", "google_apis", abi),
("36", "google_apis", abi),
]);
let r = sdk
.resolve(&ImageRef::parse("android:35").unwrap())
.unwrap();
assert_eq!(r.flavour, "google_apis");
assert_eq!(
r.package,
format!("system-images;android-35;google_apis;{abi}")
);
assert_eq!(
r.sysdir,
format!("system-images/android-35/google_apis/{abi}/")
);
}
#[test]
fn minor_versions_are_distinct() {
let abi = host_abi();
let d = crate::testutil::Dir::new();
for api in ["36", "36.1"] {
let p = d
.path()
.join(format!("system-images/android-{api}/google_apis/{abi}"));
std::fs::create_dir_all(&p).unwrap();
std::fs::write(p.join("system.img"), b"x").unwrap();
}
let sdk = Sdk {
root: d.path().to_path_buf(),
};
assert_eq!(sdk.system_images().len(), 2);
let r = sdk
.resolve(&ImageRef::parse("android:36").unwrap())
.unwrap();
assert_eq!(r.minor, None);
let r = sdk
.resolve(&ImageRef::parse("android:36.1").unwrap())
.unwrap();
assert_eq!(r.minor, Some(1));
assert!(r.package.contains("android-36.1"));
assert!(
sdk.resolve(&ImageRef::parse("android:36.2").unwrap())
.is_err()
);
assert!(sdk.find_package(&r.package).is_some());
assert_eq!(
package_for(&ImageRef::parse("android:36.1").unwrap()),
format!("system-images;android-36.1;google_apis;{abi}")
);
}
#[test]
fn explicit_flavour_and_missing() {
let abi = host_abi();
let (_d, sdk) = fake_sdk(&[("35", "google_apis_playstore", abi)]);
let r = sdk
.resolve(&ImageRef::parse("android:35-google_apis_playstore").unwrap())
.unwrap();
assert_eq!(r.flavour, "google_apis_playstore");
let err = sdk
.resolve(&ImageRef::parse("android:34").unwrap())
.unwrap_err();
assert!(err.to_string().contains("sdkmanager"));
}
#[test]
fn package_names() {
let abi = host_abi();
assert_eq!(
package_for(&ImageRef::parse("android:35").unwrap()),
format!("system-images;android-35;google_apis;{abi}")
);
assert_eq!(
package_for(&ImageRef::parse("android:34-google_apis_playstore").unwrap()),
format!("system-images;android-34;google_apis_playstore;{abi}")
);
}
#[test]
fn revision_parsing() {
let xml = "<a><revision>\n<major>9</major>\n<minor>2</minor>\n</revision><x><revision><major>1</major></revision></x></a>";
assert_eq!(parse_revision(xml).as_deref(), Some("9.2.0"));
assert_eq!(parse_revision("<a/>"), None);
}
#[test]
fn ignores_wrong_abi() {
let other = if host_abi() == "arm64-v8a" {
"x86_64"
} else {
"arm64-v8a"
};
let (_d, sdk) = fake_sdk(&[("35", "google_apis", other)]);
assert!(
sdk.resolve(&ImageRef::parse("android:35").unwrap())
.is_err()
);
}
}