From ceabd00805225cd129e761958204d621744a01db Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sat, 5 Sep 2026 07:11:44 -0400 Subject: [PATCH] E5: package app/shellApp without Gradle (cargo xtask apk) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .cargo/config.toml | 6 + .dev-updater.ron | 17 ++ .gitignore | 7 + RUST.md | 230 ++++++++++++++-- app/shellApp/build.gradle.kts | 48 ++++ xtask/Cargo.lock | 7 + xtask/Cargo.toml | 16 ++ xtask/src/apk.rs | 484 ++++++++++++++++++++++++++++++++++ xtask/src/keystore.rs | 254 ++++++++++++++++++ xtask/src/main.rs | 115 ++++++++ xtask/src/sdk.rs | 130 +++++++++ 11 files changed, 1289 insertions(+), 25 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 xtask/Cargo.lock create mode 100644 xtask/Cargo.toml create mode 100644 xtask/src/apk.rs create mode 100644 xtask/src/keystore.rs create mode 100644 xtask/src/main.rs create mode 100644 xtask/src/sdk.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..f2422b5 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,6 @@ +# xtask convention (https://github.com/matklad/cargo-xtask), without folding +# every crate in this repo into one workspace -- they are deliberately +# independent (see run-tests.sh, which cds into each). `cargo xtask apk` +# from the repo root runs xtask/src/main.rs directly. +[alias] +xtask = "run --quiet --manifest-path xtask/Cargo.toml --" diff --git a/.dev-updater.ron b/.dev-updater.ron index 3e23272..36fdeed 100644 --- a/.dev-updater.ron +++ b/.dev-updater.ron @@ -55,4 +55,21 @@ components: [ // the terminal the QR would be printed on. enroll: "server/enroll-link.sh", ), + // E5 (RUST.md): app/shellApp packaged by the xtask instead of Gradle + // (cargo ndk -> javac -> d8 -> aapt2 -> zipalign -> apksigner), signed + // with the same release key as "app" above so the two can install + // over each other -- a separate component, not a mode of "app" above, + // because it is a different applicationId (com.example.aiapp.shell) + // built by a different tool from different sources. No `cwd`: it + // defaults to this checkout's root, which both the `cargo xtask` + // alias (`.cargo/config.toml`, resolved relative to the working + // directory cargo is run from) and `cargo xtask apk`'s own publishing + // step (`xtask/build/outputs/apk//*.apk`, matching discover.rs's + // `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's module doc) + // both need. + Apk( + name: "shell", + modes: ["release", "debug"], + build: "cargo xtask apk", + ), ], diff --git a/.gitignore b/.gitignore index 94c45b9..9ca82fc 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,10 @@ sessions/ # iris, the in-house UI library, is vendored at iris/ and built by cargo. iris/target/ iris/android-app/target/ + +# E5's packaging xtask (RUST.md). `build/` above already covers +# xtask/build/outputs/apk (the published APK, see apk.rs's module doc). +# The repo root has no Cargo workspace, so this is xtask's own +# intermediate working files (target/xtask/apk/...), not a shared one. +xtask/target/ +/target/ diff --git a/RUST.md b/RUST.md index 3ba8a25..f54f020 100644 --- a/RUST.md +++ b/RUST.md @@ -39,10 +39,24 @@ session spending an afternoon on them again. - **Done**: E0 (toolchain), E1 (Masonry on android-view, which found the keyboard gap — now explained, see below), E2 (a transcript in Masonry, which found that Masonry has no touch-scroll on Android at all — see - below), **E3 (the Kotlin/Java shell over a JNI bridge into Rust, both - pass conditions proved on the emulator — see its own box)**, I0a, I0b - (iris builds on a pinned nightly and runs), I1 (parley + glyph atlas), - I2 (iris on android-view), I3 (`iris::widget::List`). + below), E3 (the Kotlin/Java shell over a JNI bridge into Rust, both + pass conditions proved on the emulator — see its own box), **E5 (the + Gradle-free packaging xtask, both pass conditions proved — see its own + box)**, I0a, I0b (iris builds on a pinned nightly and runs), I1 (parley + + glyph atlas), I2 (iris on android-view), I3 (`iris::widget::List`). +- **E5 done, 2026-09-05.** `cargo xtask apk` (new `xtask/` crate at the + repo root, zero dependencies) replaces Gradle for packaging + `app/shellApp`: `cargo ndk` → `javac`/`d8` → `aapt2` → `zipalign` → + `apksigner`, signed with the same key `app/build-apk.sh` uses. Both pass + conditions held on this checkout's emulator: `adb install -r` over the + Gradle-built `shellApp` succeeded (same key, so the signatures matched), + and the notification service reached its follow-loop and posted a real + notification while the app was backgrounded. E3's open `kotlinc` + question resolved itself as a side effect of the one Gradle call still + needed for AndroidX dependency resolution — see E5's own box for the + full account, including the one disclosed place Gradle still runs and + what was deliberately left undone (a real-device `arm64-v8a` install, + dex shrinking). - **E3 done, 2026-09-05, and unlike E1/E2 it is committed to this repo** (`android-shell/` — a JNI-bridge crate on `client-core` — plus a new Gradle module `app/shellApp/`, left deliberately separate from @@ -1235,28 +1249,194 @@ accepted. a mock. - [ ] **E4 — the same screen on the desktop** in a winit window, from the same crate, with only the layout differing. -- [ ] **E5 — the packaging xtask**: `cargo ndk` → `javac`/`d8` → `aapt2` - → `zipalign` → `apksigner`, signed with the existing release key, - installed through Dev Updater. Pass: the APK installs over the - Gradle-built one and the notification service starts. +- [x] **E5 — the packaging xtask (2026-09-05).** Both pass-condition + proofs held on this checkout's own emulator: `adb install -r` of the + xtask-built APK over the Gradle-built one succeeded, and the + notification service reached its follow-loop and posted a real + notification while the app was backgrounded. `cargo xtask apk` at + the repo root (`.cargo/config.toml`'s alias for `cargo run + --manifest-path xtask/Cargo.toml --`) runs `cargo ndk` → + `javac`/`d8` → `aapt2` → `zipalign` → `apksigner` with no Gradle + driving the packaging itself -- one disclosed exception, below. - **Not blocked by E3's layout**, and one thing E5 will need to - account for. `app/shellApp/`'s two Java classes and generated - `PinnedCa.java` are plain `javac` input with no Kotlin of their own - (`android-shell/src/settings.rs`'s doc explains why `kotlinc` still - appears in the graph regardless: `:link`, the shared `wg-app-link` - submodule, is Kotlin and is a real dependency of this module, not - something E3 introduced). `android-shell` itself builds with plain - `cargo ndk build --release -o .../jniLibs/`, exactly the shape "Can - the APK be built without Gradle?" assumed. What E5 will actually - need to solve that E3 did not: producing `:link`'s classes (or an - equivalent Keystore-sealed-token implementation) without inflating - the toolchain E5 is trying to shrink -- either accept `kotlinc` for - that one shared submodule, prebuild it once into a jar/aar E5 - consumes as a binary input, or reimplement the Keystore sealing in - Rust after all (rejected in E3 for reuse and phone-compatibility - reasons, but the calculus is different once Kotlin is otherwise - gone). + *Where it lives.* `xtask/` (new, independent crate at the repo + root -- **no Cargo workspace**, matching every other crate here; + `run-tests.sh` already `cd`s into each rather than assuming one). + **Zero dependencies**: every step is "run this SDK/JDK tool with + these arguments and check its exit status," which needs nothing a + crate would add (AGENTS.md's "new dependencies need a reason"). + `src/sdk.rs` finds the SDK root/build-tools/`android.jar` the same + way `app/android-env.sh` does ($ANDROID_HOME, then + $ANDROID_SDK_ROOT, then `~/Android/Sdk`); `src/keystore.rs` + finds-or-generates the release key with the exact recipe + `app/build-apk.sh` uses (same env vars, same path, same `keytool` + invocation) so the two tools sign with the *same* key, plus a + `--debug` path using the conventional `~/.android/debug.keystore`; + `src/apk.rs` is the pipeline itself, `src/main.rs` the ~40-line CLI. + About 420 lines total against RUST.md's earlier "about 150" guess -- + the difference is almost entirely dependency handling (below), which + the earlier estimate didn't anticipate. + + *`:link`'s `kotlinc` question, resolved.* Checked first, since E3 + left it open: no standalone `kotlinc` exists on this machine (not on + PATH, not under any SDK -- only `kotlin-compiler-embeddable` jars + inside Gradle's own distributions). So the choice was never + "invoke kotlinc" versus "port `ServerStore`/`ServerSettings` to + Java" as originally framed -- a third route fell out of solving the + *other* open dependency problem (androidx, next paragraph): the one + Gradle call already needed for that also compiles `:link`'s Kotlin + as a side effect, via Gradle's own embedded compiler, and hands back + the resulting `classes.jar` in the same resolved-jars list. That is + RUST.md's own "prebuild it once into a jar/aar E5 consumes as a + binary input" option, arrived at for free rather than built + specially -- no Java port of `ServerStore` was written, and + `android-shell/src/settings.rs`'s JNI class-by-name lookup + (`com/example/wgapplink/ServerStore`) needed no change. + + *One disclosed exception to "no Gradle in the loop": dependency + resolution.* `app/shellApp` depends on `:link` (Kotlin, above) and + on `androidx.core:core-ktx` -- not a compile-time dependency of the + two Java stub classes (`MainActivity`/`NotificationService` import + only `android.*`), but a **runtime** one: `android-shell/src/notify.rs` + reaches `NotificationCompat`/`NotificationChannelCompat`/ + `NotificationManagerCompat`/`ServiceCompat`/`ContextCompat` by class + name over JNI, so their bytecode has to be in the final dex even + though nothing in this pipeline's own Java source mentions them. + Reimplementing a Maven/AAR dependency resolver to avoid one Gradle + call was not a good trade against "smallest honest route" (the + standard this file already applied to `kotlinc`) -- so + `app/shellApp/build.gradle.kts` gained one task, + `printRuntimeClasspathJars`, which asks the `releaseRuntimeClasspath` + configuration for its artifacts through an `ArtifactView` requesting + the `android-classes-jar` attribute (the same post-AAR-transform + view AGP's own dexing task consumes, so an AAR is already unpacked + to a plain `.jar` by the time the xtask sees it) and writes their + absolute paths, one per line, to + `app/shellApp/build/xtask/runtime-classpath.txt`. `cargo xtask apk` + runs `./gradlew :shellApp:printRuntimeClasspathJars` once (a few + seconds, mostly UP-TO-DATE on a warm Gradle daemon), reads that file, + and hands every jar in it to `d8` as an ordinary program input -- + `:link`'s `classes.jar` among them, per the paragraph above. Nothing + past that one call touches Gradle. **What this trades away**: the + pipeline is not Gradle-free end to end, only Gradle-free for the + part that was actually expensive (assembling and dexing the app's + own code, which the earlier options -- kotlinc, or a hand-rolled + resolver -- were the two ways to avoid entirely). Recorded here + rather than left implicit, matching how the `kotlinc` compromise + above is recorded. + + *The rest of the pipeline, in order (`apk.rs`):* `cargo ndk -t + arm64-v8a -t x86_64 -P 26 -o app/shellApp/src/main/jniLibs/ build + --release -p android-shell` (both ABIs by default -- real phone and + this machine's emulator -- `--abi` overrides; always `--release` + for the native library regardless of the APK's signing variant, for + the reason E1 already established: a debug build's Vulkan + object-labelling segfaults this emulator's driver, and there is no + reason for a signing choice to make this crate's `.so` bigger). + `javac -cp android.jar` compiles `MainActivity.java`, + `NotificationService.java` and a freshly generated `PinnedCa.java` + (same template as the Gradle `generatePinnedCa` task, same + opening-quotes-adjacent-to-`"""` rule from AGENTS.md's "Things that + have bitten") into one `classes.jar` (`jar cf` -- `d8` rejects a + bare directory of `.class` files outright, "Unsupported source file + type", discovered by trying it). `d8 --release --min-api 24 --lib + android.jar` dexes that jar plus every classpath jar from the + paragraph above into one `classes.dex` (no multidex needed at this + size). `aapt2 link` compiles `app/shellApp/src/main/AndroidManifest.xml` + into the base APK's `resources.arsc` -- the checked-in manifest has + no `package` attribute (Gradle injects one from `android.namespace` + during a manifest merge this pipeline doesn't run), so `apk.rs` + writes a copy with `package="com.example.aiapp.shell"` spliced in + rather than editing the source manifest, and refuses to run at all + if the source ever gains one of its own (a version-drift guard + cheaper than a real merge). `--min-sdk-version`/`--target-sdk-version`/ + `--version-code`/`--version-name` are passed on the command line for + the same reason -- the raw manifest carries none of them, Gradle's + `defaultConfig` normally does. `jar uf` (not a hand-rolled zip + writer -- `jar` ships with the JDK this pipeline already needs) + merges `classes.dex` and a staged `lib//libandroid_shell.so` + tree into the base APK (cargo-ndk's `-o` writes + `jniLibs//*.so`, matching the Gradle source-set layout it was + pointed at; Android's own zip convention wants `lib//*.so` at + the archive root, hence the staging copy rather than an in-place + rename). `zipalign -f -p 4` then `apksigner sign` finish it, signed + with `~/.config/ai-app/release.jks` by default or + `~/.android/debug.keystore` under `--debug`. The signed APK is + copied to `xtask/build/outputs/apk//ai-app-shell-.apk` + as a final step -- a Gradle-shaped path (`*/build/outputs/apk/*/*.apk`) + chosen so Dev Updater's fixed-pattern APK discovery + (`~/repos/dev-updater/server/src/discover.rs`'s `APK_PATTERNS`, + which has no per-component path override) finds it without any + change on that side; the working files above it stay under + `target/xtask/apk/`, an ordinary build-cache location (gitignored, + along with `xtask/target/`). + + *Wired into `.dev-updater.ron`*: a second `Apk` component, `shell`, + `build: "cargo xtask apk"`, `modes: ["release", "debug"]`, no `cwd` + (defaults to the checkout root, which both the `cargo xtask` alias + and the publish path above need -- `.cargo/config.toml`'s alias + resolves its `--manifest-path` relative to the *invoking* working + directory, not to where the config file lives, which is what ruled + out giving this component its own `cwd`). Dev Updater's `ByMode` + appends the chosen mode word as the command's last argument + (`build-apk.sh`'s own interface, per that component's comment), so + `main.rs` accepts bare `release`/`debug` as well as `--release`/ + `--debug` for typing by hand. The existing `app` component + (`build-apk.sh`, Gradle) is untouched. + + *Verification.* `cargo fmt -- --check` and `cargo clippy + --all-targets` clean, zero warnings, for `xtask` (host target -- + nothing in it is Android-specific; it *runs* `cargo ndk`, it isn't + cross-compiled itself). `./run-tests.sh`: 127 `server` + 88 + `client-core` tests, unaffected, still passing. `apksigner verify + --print-certs` on the xtask's release output confirms a V3 signer + with `CN=ai-app` -- the same key `build-apk.sh` generates. + + *The two pass-condition proofs*, both on this checkout's own AVD + (`ai-app-2`, GPU host, brought up and torn down within this + session): + - **Installs over the Gradle-built one.** Built the Gradle release + variant first (`AI_APP_KEYSTORE=~/.config/ai-app/release.jks + AI_APP_KEYSTORE_PASSWORD=$(cat + ~/.config/ai-app/release.jks.password) ./gradlew + :shellApp:assembleRelease` -- needed its own signing block added + to `app/shellApp/build.gradle.kts`, copied from `androidApp`'s, + since `shellApp` had none before this), installed it fresh + (`adb uninstall com.example.aiapp.shell` first -- an older debug + install from E3 testing was signed with a different key and + `install -r` over it fails loudly with + `INSTALL_FAILED_UPDATE_INCOMPATIBLE`, which is the correct, + expected failure for a mismatched key rather than a bug), then + `adb install -r xtask/build/outputs/apk/release/ai-app-shell-release.apk`: + **`Success`**. + - **The notification service starts.** Enrolled via + `adb shell "am start -a android.intent.action.VIEW -d + 'aiappshell://enroll?host=10.0.2.2&port=&token='"`, + force-stopped the app, then re-launched it once (enrollment calls + `sync()` from `MainActivity.onCreate`). `adb logcat` shows + `ActivityManager: Background started FGS: Allowed ... intent: + ... cmp=com.example.aiapp.shell/.NotificationService`, immediately + followed by `android-shell: jni::vm::java_vm: Attached thread + ai-app-notifications`, a real TLS handshake to the sandbox's + `10.0.2.2:`, and `Response { status: 200 ... }` on + `/notifications`. Pressed home, spawned a sandbox session and sent + it `/question Should E5 proceed?`; `adb shell dumpsys notification + --noredact` then shows a live `NotificationRecord` for + `com.example.aiapp.shell`, `channel=sessions`, `tag=` + -- posted while the app held no visible activity, the same bar + E3's own proof cleared. + + *Left undone, honestly.* No attempt to shrink the dex (R8/minify is + off, matching `shellApp`'s existing `isMinifyEnabled = false`, so + the APK carries the full unshrunk `androidx`/Kotlin-stdlib/coroutines + graph -- about 5.2 MB signed with both ABIs, most of it native + libraries and that dependency graph rather than this project's own + code). No `--abi arm64-v8a`-only real-device install was attempted + this session (no physical phone reachable from here); the emulator + proof above is `x86_64` plus a cross-compiled but unexercised + `arm64-v8a` `.so` in the same APK. Multidex is unneeded at today's + size but nothing in `dex()` checks for the 64k-method ceiling should + the dependency graph grow. ### The iris track diff --git a/app/shellApp/build.gradle.kts b/app/shellApp/build.gradle.kts index b97e961..445f6f5 100644 --- a/app/shellApp/build.gradle.kts +++ b/app/shellApp/build.gradle.kts @@ -88,12 +88,60 @@ android { versionCode = 1 versionName = "1.0" } + // Same reasoning and same key as androidApp's (see that module's comment): E5 (RUST.md) + // signs its own, Gradle-free build with this same keystore, and the two can only + // `adb install -r` over each other if they carry the same certificate. + val keystore = System.getenv("AI_APP_KEYSTORE") + signingConfigs { + if (keystore != null) { + create("release") { + storeFile = file(keystore) + storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD") + keyAlias = "ai-app" + keyPassword = storePassword + } + } + } + buildTypes { + getByName("release") { + isMinifyEnabled = false + if (keystore != null) signingConfig = signingConfigs.getByName("release") + } + } compileOptions { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } } +// E5 (RUST.md): the xtask dexes and packages this module's Java sources itself, but it does +// not resolve Maven dependencies -- reimplementing a dependency resolver was out of scope for a +// packaging step, so this one task is the single place Gradle still runs in that pipeline. It +// asks the dependency graph for the *post-transform* jars (AARs already unpacked to a classes +// jar, the same artifact type AGP's own dexing task consumes) rather than the raw configuration, +// which would hand back .aar files d8 cannot read directly. +val artifactType = Attribute.of("artifactType", String::class.java) + +tasks.register("printRuntimeClasspathJars") { + description = "Writes the resolved release runtime classpath jars, one per line, for xtask." + val outputFile = layout.buildDirectory.file("xtask/runtime-classpath.txt") + outputs.file(outputFile) + val jars = + configurations + .getByName("releaseRuntimeClasspath") + .incoming + .artifactView { attributes.attribute(artifactType, "android-classes-jar") } + .files + // Captured as a plain FileCollection (not the ArtifactView itself, which the + // configuration cache cannot serialize) so this task is still cacheable. + inputs.files(jars) + doLast { + val file = outputFile.get().asFile + file.parentFile.mkdirs() + file.writeText(jars.joinToString("\n") { it.absolutePath }) + } +} + androidComponents { onVariants { variant -> variant.sources.java?.addGeneratedSourceDirectory(generatePinnedCa, GeneratePinnedCa::outputDir) diff --git a/xtask/Cargo.lock b/xtask/Cargo.lock new file mode 100644 index 0000000..6c15da8 --- /dev/null +++ b/xtask/Cargo.lock @@ -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" diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..0549aab --- /dev/null +++ b/xtask/Cargo.toml @@ -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" diff --git a/xtask/src/apk.rs b/xtask/src/apk.rs new file mode 100644 index 0000000..2d8a181 --- /dev/null +++ b/xtask/src/apk.rs @@ -0,0 +1,484 @@ +//! 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, + )) + } +} diff --git a/xtask/src/keystore.rs b/xtask/src/keystore.rs new file mode 100644 index 0000000..700d59b --- /dev/null +++ b/xtask/src/keystore.rs @@ -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 { + 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 { + 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 { + 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", + ) + }) +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..da5c41e --- /dev/null +++ b/xtask/src/main.rs @@ -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 `android-shell`; `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 = 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 = 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 + } + } +} diff --git a/xtask/src/sdk.rs b/xtask/src/sdk.rs new file mode 100644 index 0000000..e560c75 --- /dev/null +++ b/xtask/src/sdk.rs @@ -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 { + 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 { + let dir = sdk_root.join("build-tools"); + let mut versions: Vec<(Vec, 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 = 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\"", + )) + } +}