Files
ai-app/scripts/xtask/src/apk.rs
T
irisandClaude Opus 5 4ccfda6b8e Delete the decisions and design logs; scripts, rigs and xtask off the root
Iris: "remove both decisions and iris.md. I've decided to instead make
decisions when planning with agents rather than after they do things, and
they're both too long for me to wanna read, + don't cover all the
decisions I'll wanna make about the code anyways. I'll just naturally run
into things for now. Todo is important though."

So docs/DECISIONS.md (850 lines) and docs/IRIS.md (1,986) are gone, and
AGENTS.md now says not to start another: raise a choice while planning it
with her, otherwise decide it and put the reasoning at the code it
governs. The TODO lists stay. docs/SUBAGENTS_DECISIONS.md went with them
-- same artefact, same reasoning, and she did not name it, so its six
decisions were folded into docs/SUBAGENTS.md rather than deleted.

Deleting the logs left ~30 citations dangling in code comments and docs.
Each states its reason inline and cited the file only for provenance, so
they now read "decided 2026-09-07" or name the module doc that carries
the reasoning.

The root had six things that were not a program or a document. Moved,
per "I only meant top level sh files":

  run-tests.sh, test-wg-tunnel.sh, wg-setup-host.sh  -> scripts/
  rigs/                                              -> scripts/rigs/
  xtask/                                             -> scripts/xtask/

A project's own scripts stayed with the project: app/*.sh, app-rust/*.sh,
iris/*.sh and server/enroll-link.sh did not move.

`target/` at the root is deleted and cannot come back: there was never a
workspace there, and the 29 MB was only xtask's scratch space, now in
scripts/xtask/target/. `cargo xtask apk` still runs from the repo root
and now publishes to scripts/build/outputs/apk/<mode>/ -- one directory
deep, because that is what Dev Updater's `*/build/outputs/apk/*/*.apk`
discovery pattern needs, and scripts/xtask/build would have been two.

Verified: ./scripts/run-tests.sh and `cd iris && cargo test` green, clippy
and fmt clean everywhere, `cargo xtask apk debug --abi x86_64` builds and
signs an APK carrying lib/x86_64/libai_app.so at the new publish path, and
the repo root is now eleven entries with no build output among them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:16:24 -04:00

511 lines
19 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
//! the shell bridge'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");
// 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()?;
// xtask's own intermediate files, in its own crate's target/ rather
// than a `target/` at the repo root -- there is no workspace there and
// a build directory in the root is not something anybody was looking
// for (Iris, 2026-09-09).
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)?;
// 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 `scripts/xtask/target/apk/`, an ordinary build-cache location.
// `scripts/build/...`, not `scripts/xtask/build/...`: Dev Updater
// discovers APKs with `*/build/outputs/apk/*/*.apk` from the checkout
// root, which is exactly one directory deep. See `.dev-updater.ron`.
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> {
// xtask's own Cargo.toml is at <repo_root>/scripts/xtask/Cargo.toml,
// so the root is two levels up (2026-09-09: it used to be one).
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(|_| ())
}
/// `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",
"--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/",
)
})?;
// 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 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,
))
}
}