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>
485 lines
18 KiB
Rust
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,
|
|
))
|
|
}
|
|
}
|