//! 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 { 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//*.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 { // xtask's own Cargo.toml is at /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, 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 { 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 { 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( " 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//*.so" at the archive root, but // cargo ndk's `-o` wrote "jniLibs//*.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, )) } }