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>
This commit is contained in:
1 parent
09778346a0
commit
4ccfda6b8e
44 files changed
+198
-2994
No files matched your search
Generated
+7
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
# E5 (RUST.md): packages app/shellApp into a signed, installable APK without
|
||||
# Gradle driving the assembly (cargo ndk -> javac -> d8 -> aapt2 -> zipalign
|
||||
# -> apksigner). No dependencies beyond the standard library: every step
|
||||
# below is "run this SDK tool with these arguments and check its exit
|
||||
# status," which needs nothing a crate would help with, and every tool
|
||||
# invoked is one this project already requires (the NDK, the SDK
|
||||
# build-tools, the JDK, `cargo ndk`) -- see AGENTS.md's "new dependencies
|
||||
# need a reason."
|
||||
[[bin]]
|
||||
name = "xtask"
|
||||
path = "src/main.rs"
|
||||
@@ -0,0 +1,510 @@
|
||||
//! 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,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! The signing key. Mirrors `app/build-apk.sh`'s exact logic for the
|
||||
//! release key -- same env vars, same path, same generation recipe -- so
|
||||
//! the two tools sign with the *same* key and their outputs can
|
||||
//! `adb install -r` over each other. That is the whole point of E5's pass
|
||||
//! condition: the key has to be identical, not merely present.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::Fail;
|
||||
|
||||
pub struct Signer {
|
||||
pub keystore: PathBuf,
|
||||
pub password: String,
|
||||
pub alias: String,
|
||||
}
|
||||
|
||||
/// The release key at `$AI_APP_KEYSTORE` or
|
||||
/// `$XDG_CONFIG_HOME/ai-app/release.jks` (`~/.config/ai-app/release.jks` by
|
||||
/// default) -- generated with `keytool` if it doesn't exist yet, exactly as
|
||||
/// `build-apk.sh` does, so either tool can run first on a fresh machine.
|
||||
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 {
|
||||
// No dependency on `rand`: /dev/urandom is what build-apk.sh's `head -c
|
||||
// 24 /dev/urandom | base64` reads too, so this reproduces exactly the
|
||||
// same recipe without shelling out to head/base64/tr for it.
|
||||
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",
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! `cargo xtask apk` -- E5 (RUST.md): packages `app/shellApp` into a
|
||||
//! signed, installable APK with no Gradle in the packaging step itself.
|
||||
//! `cargo ndk` cross-compiles the app crate's `shell` feature; `javac`/`d8` turn its two
|
||||
//! Java stub classes (plus the generated pinned-CA constant) into dex;
|
||||
//! `aapt2` compiles the manifest into `resources.arsc`; the dex and native
|
||||
//! libraries are merged into that base APK with `jar`; `zipalign` and
|
||||
//! `apksigner` finish it. See `apk.rs`'s module doc for what "no Gradle in
|
||||
//! the packaging step" does and does not cover -- one disclosed exception.
|
||||
//!
|
||||
//! Usage: `cargo xtask apk [--release|--debug] [--abi ABI]...`
|
||||
|
||||
mod apk;
|
||||
mod keystore;
|
||||
mod sdk;
|
||||
|
||||
use std::fmt;
|
||||
use std::process::ExitCode;
|
||||
|
||||
/// A failure a person acts on: what went wrong, what this process actually
|
||||
/// saw, and the next thing to try. Matches CODE_RULES's "a failure message
|
||||
/// names the thing, the cause, and the fix."
|
||||
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() {
|
||||
// Bare "release"/"debug" is `.dev-updater.ron`'s interface
|
||||
// (`ByMode::One` appends the chosen mode as the build
|
||||
// command's last argument -- the same convention
|
||||
// `app/build-apk.sh`'s `${1:-release}` uses); the `--`-prefixed
|
||||
// spellings are for typing this by hand.
|
||||
"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() {
|
||||
// arm64-v8a for a real phone, x86_64 for this machine's emulator --
|
||||
// the two ABIs every other experiment in RUST.md has actually run
|
||||
// on. `--abi` overrides either way.
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! 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\"",
|
||||
))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user