Make the Rust client the sole app

This commit is contained in:
iris committed 2026-09-11 01:18:24 -04:00
1 parent a8602c1626
commit d8bb1699a8
230 files changed
+762 -27300

No files matched your search

+1 -1
View File
@@ -5,6 +5,6 @@ version = "0.1.0"
edition = "2024"
[dev-dependencies]
ai-app = { path = "../../../app-rust" }
ai-app = { path = "../../../app" }
iris = { path = "../../../iris" }
bytemuck = "1"
@@ -17,7 +17,7 @@ fn what_a_fling_frame_costs() {
h.frame(PHONE_FRAME_MS);
let flick =
TouchScript::parse(include_str!("../../../../app-rust/touch/flick-120hz.touch")).unwrap();
TouchScript::parse(include_str!("../../../../app/touch/flick-120hz.touch")).unwrap();
h.replay(&flick);
println!(
"recorded flick released at {:?}px/s; scripted passes run at {VELOCITY}px/s",
+1 -1
View File
@@ -2,6 +2,6 @@
# Extra arguments are forwarded to each workspace's `cargo test`.
set -eu
cd "$(dirname "$0")/.."
for workspace in event-model server app-rust; do
for workspace in event-model server app; do
(cd "$workspace" && cargo test "$@")
done
-7
View File
@@ -1,7 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "xtask"
version = "0.1.0"
-9
View File
@@ -1,9 +0,0 @@
[package]
name = "xtask"
version = "0.1.0"
edition = "2024"
# Packages the Compose shell APK directly with existing SDK/JDK tools.
[[bin]]
name = "xtask"
path = "src/main.rs"
-458
View File
@@ -1,458 +0,0 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::keystore::{self, Signer};
use crate::sdk::{self, Sdk};
use crate::{Fail, Variant};
const APPLICATION_ID: &str = "com.example.aiapp.shell";
pub fn build(variant: Variant, abis: &[String]) -> Result<PathBuf, Fail> {
let repo_root = repo_root()?;
let app_dir = repo_root.join("app");
let shell_app_dir = app_dir.join("shellApp");
// The JNI bridge is the `shell` feature of the one app crate now, not
// a crate of its own -- `--no-default-features` is what keeps iris,
// wgpu and parley out of a `.so` for an app that draws with Compose.
let app_crate_dir = repo_root.join("app-rust");
let sdk = sdk::find()?;
sdk::require_ndk_installed(&sdk.root)?;
require_cargo_ndk()?;
let out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join("apk");
std::fs::create_dir_all(&out_dir).map_err(|e| {
Fail::new(
"could not create the xtask output directory",
&e.to_string(),
"check permissions under scripts/xtask/target/",
)
})?;
println!("==> Building the shell JNI bridge for {}", abis.join(", "));
build_native_libs(&app_crate_dir, &shell_app_dir, &sdk, abis)?;
println!("==> Resolving the runtime classpath (one Gradle call -- see apk.rs's module doc)");
let classpath_jars = runtime_classpath_jars(&app_dir, &sdk)?;
println!("==> Compiling the Java stub classes");
let ca_pem = pinned_ca_pem()?;
let classes_jar = compile_java(&out_dir, &shell_app_dir, &sdk, &ca_pem)?;
println!("==> Dexing");
let dex_dir = out_dir.join("dex");
dex(&sdk, &classes_jar, &classpath_jars, &dex_dir)?;
println!("==> Linking resources with aapt2");
let base_apk = out_dir.join("base.apk");
aapt2_link(&sdk, &shell_app_dir, &base_apk)?;
println!("==> Merging dex and native libraries");
let merged_apk = out_dir.join("merged.apk");
merge(&base_apk, &dex_dir, &shell_app_dir, abis, &merged_apk)?;
println!(
"==> Aligning and signing ({})",
match variant {
Variant::Release => "release key",
Variant::Debug => "debug key",
}
);
let signer = match variant {
Variant::Release => keystore::release_signer()?,
Variant::Debug => keystore::debug_signer()?,
};
let variant_name = match variant {
Variant::Release => "release",
Variant::Debug => "debug",
};
let signed_apk = out_dir.join(format!("ai-app-shell-{variant_name}.apk"));
align_and_sign(&sdk, &merged_apk, &signed_apk, &signer)?;
let published_dir = repo_root
.join("scripts/build/outputs/apk")
.join(variant_name);
std::fs::create_dir_all(&published_dir).map_err(|e| {
Fail::new(
"could not create the published APK directory",
&e.to_string(),
"check permissions under scripts/build",
)
})?;
let published_apk = published_dir.join(format!("ai-app-shell-{variant_name}.apk"));
std::fs::copy(&signed_apk, &published_apk).map_err(|e| {
Fail::new(
"could not publish the signed APK",
&e.to_string(),
"check permissions under scripts/build",
)
})?;
Ok(published_apk)
}
fn repo_root() -> Result<PathBuf, Fail> {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
manifest_dir
.parent()
.and_then(Path::parent)
.map(Path::to_path_buf)
.ok_or_else(|| {
Fail::new(
"could not find the repo root",
"CARGO_MANIFEST_DIR has no grandparent",
"run through cargo, not by hand",
)
})
}
fn require_cargo_ndk() -> Result<(), Fail> {
run_checked(
Command::new("cargo").args(["ndk", "--version"]),
"cargo-ndk is not installed",
"cargo install cargo-ndk",
)
.map(|_| ())
}
fn build_native_libs(
crate_dir: &Path,
shell_app_dir: &Path,
sdk: &Sdk,
abis: &[String],
) -> Result<(), Fail> {
let jni_libs = shell_app_dir.join("src/main/jniLibs");
let mut cmd = Command::new("cargo");
cmd.current_dir(crate_dir);
cmd.arg("ndk");
for abi in abis {
cmd.args(["-t", abi]);
}
cmd.args(["-P", "26", "-o"]).arg(&jni_libs);
cmd.args([
"build",
"--release",
"--lib",
"--no-default-features",
"--features",
"shell",
]);
cmd.env("ANDROID_HOME", &sdk.root);
cmd.env("ANDROID_SDK_ROOT", &sdk.root);
run_checked(
&mut cmd,
"cargo ndk build failed",
"see the compiler output above",
)
.map(|_| ())
}
fn runtime_classpath_jars(app_dir: &Path, sdk: &Sdk) -> Result<Vec<PathBuf>, Fail> {
let mut cmd = Command::new(app_dir.join("gradlew"));
cmd.current_dir(app_dir);
cmd.args(["--console=plain", ":shellApp:printRuntimeClasspathJars"]);
cmd.env("ANDROID_HOME", &sdk.root);
cmd.env("ANDROID_SDK_ROOT", &sdk.root);
run_checked(
&mut cmd,
"resolving app/shellApp's dependencies with Gradle failed",
"see the Gradle output above",
)?;
let list_file = app_dir.join("shellApp/build/xtask/runtime-classpath.txt");
let contents = std::fs::read_to_string(&list_file).map_err(|e| {
Fail::new(
"printRuntimeClasspathJars did not produce its output file",
&format!("{}: {e}", list_file.display()),
"check app/shellApp/build.gradle.kts's printRuntimeClasspathJars task",
)
})?;
Ok(contents
.lines()
.filter(|l| !l.is_empty())
.map(PathBuf::from)
.collect())
}
/// The CA this build pins, found the same way `build-apk.sh` and
/// `androidApp`/`shellApp`'s Gradle `generatePinnedCa` tasks do:
/// `$AI_APP_CA`, else `$XDG_CONFIG_HOME/ai-app/certs/ca.pem`.
fn pinned_ca_pem() -> Result<String, Fail> {
let path = std::env::var_os("AI_APP_CA")
.map(PathBuf::from)
.unwrap_or_else(|| {
let config_home = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(std::env::var_os("HOME").unwrap()).join(".config")
});
config_home.join("ai-app").join("certs").join("ca.pem")
});
let pem = std::fs::read_to_string(&path).map_err(|e| {
Fail::new(
&format!("no CA certificate at {}", path.display()),
&e.to_string(),
"start ai-server (or app/ui-sandbox.sh) once on this machine first -- it generates the CA this build pins",
)
})?;
let pem = pem.trim().to_string();
if !pem.starts_with("-----BEGIN CERTIFICATE-----") {
return Err(Fail::new(
&format!("{} is not a PEM certificate", path.display()),
"missing the BEGIN CERTIFICATE header",
"point AI_APP_CA at a valid one",
));
}
Ok(pem)
}
fn compile_java(
out_dir: &Path,
shell_app_dir: &Path,
sdk: &Sdk,
ca_pem: &str,
) -> Result<PathBuf, Fail> {
let gen_dir = out_dir.join("generated-java");
let package_dir = gen_dir.join("com/example/aiapp/shell");
std::fs::create_dir_all(&package_dir).map_err(|e| {
Fail::new(
"could not create the generated-sources directory",
&e.to_string(),
"check permissions under scripts/xtask/target/",
)
})?;
let pinned_ca_java = format!(
"package com.example.aiapp.shell;\n\npublic final class PinnedCa {{\n private PinnedCa() {{}}\n public static final String PINNED_CA_PEM = \"\"\"\n{ca_pem}\"\"\";\n}}\n"
);
std::fs::write(package_dir.join("PinnedCa.java"), pinned_ca_java).map_err(|e| {
Fail::new(
"could not write PinnedCa.java",
&e.to_string(),
"check permissions under scripts/xtask/target/",
)
})?;
let classes_dir = out_dir.join("classes");
std::fs::create_dir_all(&classes_dir).map_err(|e| {
Fail::new(
"could not create the classes directory",
&e.to_string(),
"check permissions under scripts/xtask/target/",
)
})?;
let java_dir = shell_app_dir.join("src/main/java/com/example/aiapp/shell");
let mut cmd = Command::new("javac");
cmd.args(["-cp"]).arg(&sdk.android_jar);
cmd.args(["-d"]).arg(&classes_dir);
cmd.arg(java_dir.join("MainActivity.java"));
cmd.arg(java_dir.join("NotificationService.java"));
cmd.arg(package_dir.join("PinnedCa.java"));
run_checked(&mut cmd, "javac failed", "see the compiler output above")?;
let classes_jar = out_dir.join("classes.jar");
let mut cmd = Command::new("jar");
cmd.current_dir(&classes_dir);
cmd.args(["cf"])
.arg(&classes_jar)
.args(["-C", "."])
.arg(".");
run_checked(
&mut cmd,
"jar failed to package the compiled classes",
"see the output above",
)?;
Ok(classes_jar)
}
fn dex(
sdk: &Sdk,
classes_jar: &Path,
classpath_jars: &[PathBuf],
dex_dir: &Path,
) -> Result<(), Fail> {
std::fs::create_dir_all(dex_dir).map_err(|e| {
Fail::new(
"could not create the dex output directory",
&e.to_string(),
"check permissions under scripts/xtask/target/",
)
})?;
let mut cmd = Command::new(sdk.tool("d8"));
cmd.args(["--release", "--min-api"])
.arg(sdk::MIN_SDK.to_string());
cmd.arg("--lib").arg(&sdk.android_jar);
cmd.arg("--output").arg(dex_dir);
cmd.arg(classes_jar);
cmd.args(classpath_jars);
run_checked(&mut cmd, "d8 failed", "see the compiler output above").map(|_| ())
}
fn aapt2_link(sdk: &Sdk, shell_app_dir: &Path, base_apk: &Path) -> Result<(), Fail> {
let manifest_src = shell_app_dir.join("src/main/AndroidManifest.xml");
let manifest_text = std::fs::read_to_string(&manifest_src).map_err(|e| {
Fail::new(
"could not read the manifest",
&format!("{}: {e}", manifest_src.display()),
"check app/shellApp/src/main/AndroidManifest.xml",
)
})?;
// The checked-in manifest has no `package` attribute -- Gradle injects
// it from `android.namespace` during its own manifest merge, which
// this pipeline does not run. aapt2 needs it to know what package to
// generate resources under.
if manifest_text.contains("package=") {
return Err(Fail::new(
"app/shellApp's manifest already has a package attribute",
"aapt2_link() assumes it doesn't and injects one",
"update aapt2_link() in scripts/xtask/src/apk.rs to stop injecting a second one",
));
}
let merged_manifest = manifest_text.replacen(
"<manifest ",
&format!("<manifest package=\"{APPLICATION_ID}\" "),
1,
);
let merged_manifest_path = base_apk.with_file_name("AndroidManifest.merged.xml");
std::fs::write(&merged_manifest_path, merged_manifest).map_err(|e| {
Fail::new(
"could not write the merged manifest",
&e.to_string(),
"check permissions under scripts/xtask/target/",
)
})?;
let mut cmd = Command::new(sdk.tool("aapt2"));
cmd.args(["link", "-o"]).arg(base_apk);
cmd.args(["--manifest"]).arg(&merged_manifest_path);
cmd.arg("-I").arg(&sdk.android_jar);
cmd.args(["--min-sdk-version", &sdk::MIN_SDK.to_string()]);
cmd.args(["--target-sdk-version", &sdk::COMPILE_SDK.to_string()]);
cmd.args(["--version-code", "1", "--version-name", "1.0"]);
run_checked(&mut cmd, "aapt2 link failed", "see the output above").map(|_| ())
}
fn merge(
base_apk: &Path,
dex_dir: &Path,
shell_app_dir: &Path,
abis: &[String],
merged_apk: &Path,
) -> Result<(), Fail> {
std::fs::copy(base_apk, merged_apk).map_err(|e| {
Fail::new(
"could not copy the base APK",
&e.to_string(),
"check permissions under scripts/xtask/target/",
)
})?;
let mut cmd = Command::new("jar");
cmd.current_dir(dex_dir);
cmd.args(["uf"])
.arg(std::path::absolute(merged_apk).unwrap_or_else(|_| merged_apk.to_path_buf()));
cmd.args(["classes.dex"]);
run_checked(
&mut cmd,
"jar failed to add classes.dex to the APK",
"see the output above",
)?;
// Android's zip layout wants "lib/<abi>/*.so" at the archive root, but
// cargo ndk's `-o` wrote "jniLibs/<abi>/*.so" (matching the Gradle
// source-set layout it was pointed at) -- so this stages a "lib/"
// directory rather than trying to rename inside the zip.
let stage = merged_apk.with_file_name("lib-stage");
if stage.exists() {
std::fs::remove_dir_all(&stage).ok();
}
for abi in abis {
let so_name = "libai_app.so";
let src = shell_app_dir
.join("src/main/jniLibs")
.join(abi)
.join(so_name);
if !src.is_file() {
return Err(Fail::new(
&format!("no native library built for {abi}"),
&format!("expected {}", src.display()),
"check cargo ndk's output above for that ABI",
));
}
let dest_dir = stage.join("lib").join(abi);
std::fs::create_dir_all(&dest_dir).map_err(|e| {
Fail::new(
"could not stage the native library",
&e.to_string(),
"check permissions under scripts/xtask/target/",
)
})?;
std::fs::copy(&src, dest_dir.join(so_name)).map_err(|e| {
Fail::new(
"could not stage the native library",
&e.to_string(),
"check permissions under scripts/xtask/target/",
)
})?;
}
let mut cmd = Command::new("jar");
cmd.current_dir(&stage);
cmd.args(["uf"])
.arg(std::path::absolute(merged_apk).unwrap_or_else(|_| merged_apk.to_path_buf()));
cmd.arg("lib");
run_checked(
&mut cmd,
"jar failed to add the native libraries to the APK",
"see the output above",
)
.map(|_| ())
}
fn align_and_sign(
sdk: &Sdk,
merged_apk: &Path,
final_apk: &Path,
signer: &Signer,
) -> Result<(), Fail> {
let aligned_apk = merged_apk.with_file_name("aligned.apk");
let mut cmd = Command::new(sdk.tool("zipalign"));
cmd.args(["-f", "-p", "4"])
.arg(merged_apk)
.arg(&aligned_apk);
run_checked(&mut cmd, "zipalign failed", "see the output above")?;
let mut cmd = Command::new(sdk.tool("apksigner"));
cmd.args(["sign", "--ks"]).arg(&signer.keystore);
cmd.arg("--ks-pass")
.arg(format!("pass:{}", signer.password));
cmd.arg("--ks-key-alias").arg(&signer.alias);
cmd.arg("--out").arg(final_apk);
cmd.arg(&aligned_apk);
run_checked(
&mut cmd,
"apksigner failed to sign the APK",
"see the output above",
)
.map(|_| ())
}
fn run_checked(cmd: &mut Command, what: &str, fix: &str) -> Result<(), Fail> {
let status = cmd.status().map_err(|e| {
Fail::new(
what,
&format!("could not run {:?}: {e}", cmd.get_program()),
fix,
)
})?;
if status.success() {
Ok(())
} else {
Err(Fail::new(
what,
&format!("{:?} exited with {status}", cmd.get_program()),
fix,
))
}
}
-241
View File
@@ -1,241 +0,0 @@
use std::path::PathBuf;
use std::process::Command;
use crate::Fail;
pub struct Signer {
pub keystore: PathBuf,
pub password: String,
pub alias: String,
}
pub fn release_signer() -> Result<Signer, Fail> {
let keystore = std::env::var_os("AI_APP_KEYSTORE")
.map(PathBuf::from)
.unwrap_or_else(|| {
let config_home = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(std::env::var_os("HOME").unwrap()).join(".config")
});
config_home.join("ai-app").join("release.jks")
});
let alias = "ai-app".to_string();
let password_file = keystore.with_extension("jks.password");
if keystore.is_file() {
let password = std::fs::read_to_string(&password_file)
.map_err(|e| {
Fail::new(
"release key exists but its password file is unreadable",
&format!("{}: {e}", password_file.display()),
"restore the password file, or delete both and let this regenerate them",
)
})?
.trim()
.to_string();
return Ok(Signer {
keystore,
password,
alias,
});
}
let keytool = which_keytool()?;
if let Some(parent) = keystore.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
Fail::new(
"could not create the keystore's directory",
&format!("{}: {e}", parent.display()),
"check permissions on that path",
)
})?;
}
let password = random_password();
write_owner_only(&password_file, format!("{password}\n").as_bytes())?;
let status = Command::new(&keytool)
.args(["-genkeypair", "-keystore"])
.arg(&keystore)
.args([
"-alias",
&alias,
"-keyalg",
"RSA",
"-keysize",
"2048",
"-validity",
"10000",
])
.args(["-storepass", &password, "-keypass", &password])
.args(["-dname", "CN=ai-app"])
.status()
.map_err(|e| {
Fail::new(
"failed to run keytool",
&format!("{}: {e}", keytool.display()),
"set JAVA_HOME to the JDK Gradle uses",
)
})?;
if !status.success() {
return Err(Fail::new(
"keytool exited with an error while generating the release key",
&format!("status: {status}"),
"check the keytool output above",
));
}
// Owner-only, matching build-apk.sh -- this key is what the phone
// recognises the app by, so it never goes in the repo and it stays
// unreadable to anything else on this machine.
set_owner_only(&keystore)?;
Ok(Signer {
keystore,
password,
alias,
})
}
/// The conventional Android debug key (`~/.android/debug.keystore`,
/// well-known password `android`, alias `androiddebugkey`) -- generated on
/// first use exactly the way Android Studio and Gradle's own debug signing
/// config do, so a `--debug` build here needs no setup and never touches
/// the real release key.
pub fn debug_signer() -> Result<Signer, Fail> {
let home = PathBuf::from(std::env::var_os("HOME").ok_or_else(|| {
Fail::new(
"no $HOME set",
"the debug keystore lives under ~/.android",
"set $HOME",
)
})?);
let keystore = home.join(".android").join("debug.keystore");
let alias = "androiddebugkey".to_string();
let password = "android".to_string();
if !keystore.is_file() {
let keytool = which_keytool()?;
std::fs::create_dir_all(keystore.parent().unwrap()).map_err(|e| {
Fail::new(
"could not create ~/.android",
&format!("{e}"),
"check permissions on your home directory",
)
})?;
let status = Command::new(&keytool)
.args(["-genkeypair", "-keystore"])
.arg(&keystore)
.args([
"-alias",
&alias,
"-keyalg",
"RSA",
"-keysize",
"2048",
"-validity",
"10000",
])
.args(["-storepass", &password, "-keypass", &password])
.args(["-dname", "CN=Android Debug,O=Android,C=US"])
.status()
.map_err(|e| {
Fail::new(
"failed to run keytool",
&format!("{}: {e}", keytool.display()),
"set JAVA_HOME to the JDK Gradle uses",
)
})?;
if !status.success() {
return Err(Fail::new(
"keytool exited with an error while generating the debug key",
&format!("status: {status}"),
"check the keytool output above",
));
}
}
Ok(Signer {
keystore,
password,
alias,
})
}
fn which_keytool() -> Result<PathBuf, Fail> {
if let Some(java_home) = std::env::var_os("JAVA_HOME") {
let candidate = PathBuf::from(java_home).join("bin").join("keytool");
if candidate.is_file() {
return Ok(candidate);
}
}
if Command::new("keytool").arg("-help").output().is_ok() {
return Ok(PathBuf::from("keytool"));
}
Err(Fail::new(
"no keytool available to generate the release key",
"checked $JAVA_HOME/bin/keytool and keytool on PATH",
"set JAVA_HOME to the JDK Gradle uses, or set AI_APP_KEYSTORE to an existing key",
))
}
fn random_password() -> String {
let mut bytes = [0u8; 24];
std::fs::File::open("/dev/urandom")
.and_then(|mut f| std::io::Read::read_exact(&mut f, &mut bytes))
.expect("/dev/urandom must be readable to generate a signing key password");
base64_no_padding(&bytes)
}
fn base64_no_padding(bytes: &[u8]) -> String {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = *chunk.get(1).unwrap_or(&0) as u32;
let b2 = *chunk.get(2).unwrap_or(&0) as u32;
let n = (b0 << 16) | (b1 << 8) | b2;
out.push(ALPHABET[(n >> 18 & 0x3f) as usize] as char);
out.push(ALPHABET[(n >> 12 & 0x3f) as usize] as char);
if chunk.len() > 1 {
out.push(ALPHABET[(n >> 6 & 0x3f) as usize] as char);
}
if chunk.len() > 2 {
out.push(ALPHABET[(n & 0x3f) as usize] as char);
}
}
// build-apk.sh strips '/', '+' and '=' from its password (tr -d
// '/+='), so the value never needs quoting when it is passed as a
// command-line argument later.
out.retain(|c| c != '/' && c != '+' && c != '=');
out
}
#[cfg(unix)]
fn write_owner_only(path: &std::path::Path, contents: &[u8]) -> Result<(), Fail> {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.and_then(|mut f| std::io::Write::write_all(&mut f, contents))
.map_err(|e| {
Fail::new(
"could not write the keystore password file",
&format!("{}: {e}", path.display()),
"check permissions on that directory",
)
})
}
#[cfg(unix)]
fn set_owner_only(path: &std::path::Path) -> Result<(), Fail> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|e| {
Fail::new(
"could not restrict the keystore's permissions",
&format!("{}: {e}", path.display()),
"chmod 600 it by hand",
)
})
}
-93
View File
@@ -1,93 +0,0 @@
mod apk;
mod keystore;
mod sdk;
use std::fmt;
use std::process::ExitCode;
pub struct Fail {
what: String,
cause: String,
fix: String,
}
impl Fail {
pub fn new(what: &str, cause: &str, fix: &str) -> Self {
Fail {
what: what.to_string(),
cause: cause.to_string(),
fix: fix.to_string(),
}
}
}
impl fmt::Display for Fail {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}\n cause: {}\n fix: {}",
self.what, self.cause, self.fix
)
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Variant {
/// Signed with `~/.config/ai-app/release.jks`, the same key
/// `build-apk.sh` uses for `androidApp` -- what E5's pass condition
/// needs, since installing over an existing app requires a matching
/// signature.
Release,
/// Signed with the standard Android debug keystore
/// (`~/.android/debug.keystore`, well-known password, generated if
/// missing the same way Gradle would), for a fast local loop that
/// doesn't touch the real signing key.
Debug,
}
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let Some(("apk", rest)) = args.split_first().map(|(cmd, rest)| (cmd.as_str(), rest)) else {
eprintln!("usage: cargo xtask apk [release|debug] [--abi ABI]...");
return ExitCode::FAILURE;
};
let mut variant = Variant::Release;
let mut abis: Vec<String> = Vec::new();
let mut i = 0;
while i < rest.len() {
match rest[i].as_str() {
"release" | "--release" => variant = Variant::Release,
"debug" | "--debug" => variant = Variant::Debug,
"--abi" => {
i += 1;
match rest.get(i) {
Some(abi) => abis.push(abi.clone()),
None => {
eprintln!("--abi needs a value (e.g. arm64-v8a, x86_64)");
return ExitCode::FAILURE;
}
}
}
other => {
eprintln!("unknown argument: {other}");
return ExitCode::FAILURE;
}
}
i += 1;
}
if abis.is_empty() {
abis = vec!["arm64-v8a".to_string(), "x86_64".to_string()];
}
match apk::build(variant, &abis) {
Ok(path) => {
println!("==> Built {}", path.display());
ExitCode::SUCCESS
}
Err(fail) => {
eprintln!("xtask: {fail}");
ExitCode::FAILURE
}
}
}
-130
View File
@@ -1,130 +0,0 @@
//! Finds the Android SDK/NDK pieces the packaging pipeline needs, the same
//! way `app/android-env.sh` and `app/build-apk.sh` do: `$ANDROID_HOME`, then
//! `$ANDROID_SDK_ROOT`, then `~/Android/Sdk`. Kept in one place because
//! every step in `main.rs` needs at least one of these paths, and a
//! mismatch between them (an `android.jar` from one SDK, `d8` from
//! another) fails in ways that point at the wrong cause.
use std::path::{Path, PathBuf};
use crate::Fail;
/// compileSdk / targetSdk, matching `app/shellApp/build.gradle.kts`. Not
/// read from that file -- if the two drift, `android.jar` or a platform
/// tools directory goes missing and the error below names the exact path
/// that wasn't there, which is no harder to act on than a parsed number
/// would have been.
pub const COMPILE_SDK: u32 = 37;
pub const MIN_SDK: u32 = 24;
pub struct Sdk {
pub root: PathBuf,
pub build_tools: PathBuf,
pub android_jar: PathBuf,
}
impl Sdk {
pub fn tool(&self, name: &str) -> PathBuf {
self.build_tools.join(name)
}
}
pub fn find() -> Result<Sdk, Fail> {
let root = std::env::var_os("ANDROID_HOME")
.or_else(|| std::env::var_os("ANDROID_SDK_ROOT"))
.map(PathBuf::from)
.filter(|p| p.is_dir())
.or_else(|| {
let home = std::env::var_os("HOME").map(PathBuf::from)?;
let candidate = home.join("Android/Sdk");
candidate.is_dir().then_some(candidate)
})
.ok_or_else(|| {
Fail::new(
"no Android SDK found",
"checked $ANDROID_HOME, $ANDROID_SDK_ROOT and ~/Android/Sdk",
"set ANDROID_HOME, or run app/android-env.sh once to install one",
)
})?;
let build_tools = latest_build_tools(&root)?;
let android_jar = root
.join("platforms")
.join(format!("android-{COMPILE_SDK}.0"))
.join("android.jar");
let android_jar = if android_jar.is_file() {
android_jar
} else {
// Some installs use the bare "android-37" directory name instead of
// "android-37.0" -- both exist on this machine's SDK depending on
// how the platform was installed, so try the other spelling before
// giving up.
let alt = root
.join("platforms")
.join(format!("android-{COMPILE_SDK}"))
.join("android.jar");
if alt.is_file() {
alt
} else {
return Err(Fail::new(
&format!("no android.jar for API {COMPILE_SDK}"),
&format!("checked {} and {}", android_jar.display(), alt.display()),
&format!("install it: android sdk install \"platforms/android-{COMPILE_SDK}.0\""),
));
}
};
Ok(Sdk {
root,
build_tools,
android_jar,
})
}
fn latest_build_tools(sdk_root: &Path) -> Result<PathBuf, Fail> {
let dir = sdk_root.join("build-tools");
let mut versions: Vec<(Vec<u32>, PathBuf)> = std::fs::read_dir(&dir)
.map_err(|e| {
Fail::new(
"no build-tools directory in the Android SDK",
&format!("{}: {e}", dir.display()),
"install one: android sdk install \"build-tools;37.0.0\"",
)
})?
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().is_dir())
.filter_map(|entry| {
let name = entry.file_name();
let name = name.to_str()?;
let parts: Vec<u32> = name.split('.').filter_map(|p| p.parse().ok()).collect();
(!parts.is_empty()).then_some((parts, entry.path()))
})
.collect();
versions.sort();
versions.pop().map(|(_, path)| path).ok_or_else(|| {
Fail::new(
"no usable build-tools version found",
&format!("{} has no version-numbered subdirectory", dir.display()),
"install one: android sdk install \"build-tools;37.0.0\"",
)
})
}
/// The NDK version `cargo ndk` should find on its own by scanning
/// `$ANDROID_HOME/ndk/*` -- this just checks one exists, so a missing NDK
/// is reported before `cargo ndk` does it with a less specific message.
pub fn require_ndk_installed(sdk_root: &Path) -> Result<(), Fail> {
let ndk_dir = sdk_root.join("ndk");
let has_one = std::fs::read_dir(&ndk_dir)
.map(|entries| entries.filter_map(|e| e.ok()).any(|e| e.path().is_dir()))
.unwrap_or(false);
if has_one {
Ok(())
} else {
Err(Fail::new(
"no NDK installed under the Android SDK",
&format!("{} has no version subdirectory", ndk_dir.display()),
"install one: android sdk install \"ndk;29.0.14206865\"",
))
}
}