Files
ai-app/xtask/src/apk.rs
T
irisandClaude Sonnet ceabd00805 E5: package app/shellApp without Gradle (cargo xtask apk)
New xtask/ crate (no deps) runs cargo ndk -> javac -> d8 -> aapt2 ->
zipalign -> apksigner directly, signed with the same key build-apk.sh
uses. Both pass conditions proved on the ai-app-2 emulator: the xtask
APK installs over the Gradle-built shellApp, and the notification
service starts and posts a real notification while backgrounded.

Adds one printRuntimeClasspathJars task to shellApp/build.gradle.kts
(and a matching signingConfig) -- the one disclosed Gradle call the
xtask still makes, to resolve the AndroidX/​:link dependency graph.
That call's Kotlin compilation of :link as a side effect also answers
E3's open kotlinc question, so no Java port of ServerStore was needed.
Wires a second Apk component into .dev-updater.ron beside the existing
one. Full writeup in RUST.md's E5 box.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-05 07:11:44 -04:00

485 lines
18 KiB
Rust

//! The pipeline itself: `cargo ndk` -> `javac`/`d8` -> `aapt2` ->
//! `zipalign` -> `apksigner`, with no Gradle driving *this* file's steps.
//!
//! **One disclosed exception**, recorded here rather than left to be
//! rediscovered: step 3 below still runs `./gradlew
//! :shellApp:printRuntimeClasspathJars` once, because `app/shellApp`
//! depends on the `:link` submodule (Kotlin: `ServerStore`/`ServerSettings`,
//! the Keystore-sealed enrollment, RUST.md's E3 entry explains why that
//! code is reused rather than re-derived in Rust) and on
//! `androidx.core:core-ktx` (used at runtime through JNI by
//! `android-shell`'s `notify.rs`, for `NotificationCompat` and friends).
//! Both are ordinary Maven/AAR dependency graphs, and reimplementing a
//! dependency resolver to avoid one Gradle invocation was not a good trade
//! against "smallest honest route" (RUST.md's E5 box) -- especially since
//! that one call also compiles `:link`'s Kotlin as a side effect, using
//! Gradle's own embedded Kotlin compiler. This machine has no standalone
//! `kotlinc` (checked: not on PATH, not under any SDK), so that side
//! effect is what answers E3's open question about `kotlinc` -- see
//! RUST.md's E5 entry for the full account. Nothing past this one call
//! touches Gradle: `javac`, `d8`, `aapt2`, `zipalign` and `apksigner` are
//! invoked directly, and the jars this call resolves are consumed as
//! plain binary inputs to `d8`, exactly like any other pre-built `.jar`.
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");
let android_shell_dir = repo_root.join("android-shell");
let sdk = sdk::find()?;
sdk::require_ndk_installed(&sdk.root)?;
require_cargo_ndk()?;
let out_dir = repo_root.join("target").join("xtask").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 target/",
)
})?;
println!("==> Building android-shell for {}", abis.join(", "));
build_native_libs(&android_shell_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)?;
// Copied into a Gradle-shaped path (`build/outputs/apk/<mode>/*.apk`
// under this xtask's own directory) as the final step, purely so Dev
// Updater's fixed-pattern APK discovery (`discover.rs`'s
// `APK_PATTERNS`, which has no per-component path override) finds it
// without needing a change on that side -- `.dev-updater.ron`'s
// `shell` component points its `cwd` here. The working files above
// stay under `target/xtask/apk/`, an ordinary build-cache location.
let published_dir = repo_root.join("xtask/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 xtask/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 xtask/build",
)
})?;
Ok(published_apk)
}
fn repo_root() -> Result<PathBuf, Fail> {
// xtask's own Cargo.toml is at <repo_root>/xtask/Cargo.toml.
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
manifest_dir.parent().map(Path::to_path_buf).ok_or_else(|| {
Fail::new(
"could not find the repo root",
"CARGO_MANIFEST_DIR has no parent",
"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(|_| ())
}
/// `cargo ndk`'s `-t` target name for each ABI, and `-P 26` -- the API
/// level every other cross-compile in this repo uses (RUST.md: E0, E1, E3,
/// I2), kept consistent here rather than picked fresh.
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);
// Always the release profile for the native library, independent of
// the APK's signing variant -- a debug build's Vulkan object-labelling
// segfaults this emulator's driver (RUST.md's E1 entry), and there is
// no reason for this crate's debug build to be bigger or slower for a
// signing choice that has nothing to do with it.
cmd.args(["build", "--release", "-p", "android-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 target/",
)
})?;
// Same shape as shellApp's Gradle `generatePinnedCa` task: the text
// block must start immediately after the opening `"""`, or
// CertificateFactory stops recognising the "-----BEGIN" preamble (a
// real bug this project hit once -- see AGENTS.md's "Things that have
// bitten").
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 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 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 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 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 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 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 = "libandroid_shell.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 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 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,
))
}
}