E5: package app/shellApp without Gradle (cargo xtask apk)
New xtask/ crate (no deps) runs cargo ndk -> javac -> d8 -> aapt2 -> zipalign -> apksigner directly, signed with the same key build-apk.sh uses. Both pass conditions proved on the ai-app-2 emulator: the xtask APK installs over the Gradle-built shellApp, and the notification service starts and posts a real notification while backgrounded. Adds one printRuntimeClasspathJars task to shellApp/build.gradle.kts (and a matching signingConfig) -- the one disclosed Gradle call the xtask still makes, to resolve the AndroidX/:link dependency graph. That call's Kotlin compilation of :link as a side effect also answers E3's open kotlinc question, so no Java port of ServerStore was needed. Wires a second Apk component into .dev-updater.ron beside the existing one. Full writeup in RUST.md's E5 box. Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
This commit is contained in:
1 parent
32a5256a0d
commit
ceabd00805
11 files changed
+1289
-25
No files matched your search
@@ -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 --"
|
||||
@@ -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/<mode>/*.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",
|
||||
),
|
||||
],
|
||||
@@ -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/
|
||||
@@ -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/<abi>/libandroid_shell.so`
|
||||
tree into the base APK (cargo-ndk's `-o` writes
|
||||
`jniLibs/<abi>/*.so`, matching the Gradle source-set layout it was
|
||||
pointed at; Android's own zip convention wants `lib/<abi>/*.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/<mode>/ai-app-shell-<mode>.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=<sandbox port>&token=<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:<port>`, 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=<session id>`
|
||||
-- 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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
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,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<PathBuf, Fail> {
|
||||
let repo_root = repo_root()?;
|
||||
let app_dir = repo_root.join("app");
|
||||
let shell_app_dir = app_dir.join("shellApp");
|
||||
let android_shell_dir = repo_root.join("android-shell");
|
||||
|
||||
let sdk = sdk::find()?;
|
||||
sdk::require_ndk_installed(&sdk.root)?;
|
||||
require_cargo_ndk()?;
|
||||
|
||||
let out_dir = repo_root.join("target").join("xtask").join("apk");
|
||||
std::fs::create_dir_all(&out_dir).map_err(|e| {
|
||||
Fail::new(
|
||||
"could not create the xtask output directory",
|
||||
&e.to_string(),
|
||||
"check permissions under target/",
|
||||
)
|
||||
})?;
|
||||
|
||||
println!("==> Building android-shell for {}", abis.join(", "));
|
||||
build_native_libs(&android_shell_dir, &shell_app_dir, &sdk, abis)?;
|
||||
|
||||
println!("==> Resolving the runtime classpath (one Gradle call -- see apk.rs's module doc)");
|
||||
let classpath_jars = runtime_classpath_jars(&app_dir, &sdk)?;
|
||||
|
||||
println!("==> Compiling the Java stub classes");
|
||||
let ca_pem = pinned_ca_pem()?;
|
||||
let classes_jar = compile_java(&out_dir, &shell_app_dir, &sdk, &ca_pem)?;
|
||||
|
||||
println!("==> Dexing");
|
||||
let dex_dir = out_dir.join("dex");
|
||||
dex(&sdk, &classes_jar, &classpath_jars, &dex_dir)?;
|
||||
|
||||
println!("==> Linking resources with aapt2");
|
||||
let base_apk = out_dir.join("base.apk");
|
||||
aapt2_link(&sdk, &shell_app_dir, &base_apk)?;
|
||||
|
||||
println!("==> Merging dex and native libraries");
|
||||
let merged_apk = out_dir.join("merged.apk");
|
||||
merge(&base_apk, &dex_dir, &shell_app_dir, abis, &merged_apk)?;
|
||||
|
||||
println!(
|
||||
"==> Aligning and signing ({})",
|
||||
match variant {
|
||||
Variant::Release => "release key",
|
||||
Variant::Debug => "debug key",
|
||||
}
|
||||
);
|
||||
let signer = match variant {
|
||||
Variant::Release => keystore::release_signer()?,
|
||||
Variant::Debug => keystore::debug_signer()?,
|
||||
};
|
||||
let variant_name = match variant {
|
||||
Variant::Release => "release",
|
||||
Variant::Debug => "debug",
|
||||
};
|
||||
let signed_apk = out_dir.join(format!("ai-app-shell-{variant_name}.apk"));
|
||||
align_and_sign(&sdk, &merged_apk, &signed_apk, &signer)?;
|
||||
|
||||
// Copied into a Gradle-shaped path (`build/outputs/apk/<mode>/*.apk`
|
||||
// under this xtask's own directory) as the final step, purely so Dev
|
||||
// Updater's fixed-pattern APK discovery (`discover.rs`'s
|
||||
// `APK_PATTERNS`, which has no per-component path override) finds it
|
||||
// without needing a change on that side -- `.dev-updater.ron`'s
|
||||
// `shell` component points its `cwd` here. The working files above
|
||||
// stay under `target/xtask/apk/`, an ordinary build-cache location.
|
||||
let published_dir = repo_root.join("xtask/build/outputs/apk").join(variant_name);
|
||||
std::fs::create_dir_all(&published_dir).map_err(|e| {
|
||||
Fail::new(
|
||||
"could not create the published APK directory",
|
||||
&e.to_string(),
|
||||
"check permissions under xtask/build",
|
||||
)
|
||||
})?;
|
||||
let published_apk = published_dir.join(format!("ai-app-shell-{variant_name}.apk"));
|
||||
std::fs::copy(&signed_apk, &published_apk).map_err(|e| {
|
||||
Fail::new(
|
||||
"could not publish the signed APK",
|
||||
&e.to_string(),
|
||||
"check permissions under xtask/build",
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(published_apk)
|
||||
}
|
||||
|
||||
fn repo_root() -> Result<PathBuf, Fail> {
|
||||
// xtask's own Cargo.toml is at <repo_root>/xtask/Cargo.toml.
|
||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
manifest_dir.parent().map(Path::to_path_buf).ok_or_else(|| {
|
||||
Fail::new(
|
||||
"could not find the repo root",
|
||||
"CARGO_MANIFEST_DIR has no parent",
|
||||
"run through cargo, not by hand",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn require_cargo_ndk() -> Result<(), Fail> {
|
||||
run_checked(
|
||||
Command::new("cargo").args(["ndk", "--version"]),
|
||||
"cargo-ndk is not installed",
|
||||
"cargo install cargo-ndk",
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// `cargo ndk`'s `-t` target name for each ABI, and `-P 26` -- the API
|
||||
/// level every other cross-compile in this repo uses (RUST.md: E0, E1, E3,
|
||||
/// I2), kept consistent here rather than picked fresh.
|
||||
fn build_native_libs(
|
||||
crate_dir: &Path,
|
||||
shell_app_dir: &Path,
|
||||
sdk: &Sdk,
|
||||
abis: &[String],
|
||||
) -> Result<(), Fail> {
|
||||
let jni_libs = shell_app_dir.join("src/main/jniLibs");
|
||||
let mut cmd = Command::new("cargo");
|
||||
cmd.current_dir(crate_dir);
|
||||
cmd.arg("ndk");
|
||||
for abi in abis {
|
||||
cmd.args(["-t", abi]);
|
||||
}
|
||||
cmd.args(["-P", "26", "-o"]).arg(&jni_libs);
|
||||
// Always the release profile for the native library, independent of
|
||||
// the APK's signing variant -- a debug build's Vulkan object-labelling
|
||||
// segfaults this emulator's driver (RUST.md's E1 entry), and there is
|
||||
// no reason for this crate's debug build to be bigger or slower for a
|
||||
// signing choice that has nothing to do with it.
|
||||
cmd.args(["build", "--release", "-p", "android-shell"]);
|
||||
cmd.env("ANDROID_HOME", &sdk.root);
|
||||
cmd.env("ANDROID_SDK_ROOT", &sdk.root);
|
||||
run_checked(
|
||||
&mut cmd,
|
||||
"cargo ndk build failed",
|
||||
"see the compiler output above",
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
fn runtime_classpath_jars(app_dir: &Path, sdk: &Sdk) -> Result<Vec<PathBuf>, Fail> {
|
||||
let mut cmd = Command::new(app_dir.join("gradlew"));
|
||||
cmd.current_dir(app_dir);
|
||||
cmd.args(["--console=plain", ":shellApp:printRuntimeClasspathJars"]);
|
||||
cmd.env("ANDROID_HOME", &sdk.root);
|
||||
cmd.env("ANDROID_SDK_ROOT", &sdk.root);
|
||||
run_checked(
|
||||
&mut cmd,
|
||||
"resolving app/shellApp's dependencies with Gradle failed",
|
||||
"see the Gradle output above",
|
||||
)?;
|
||||
|
||||
let list_file = app_dir.join("shellApp/build/xtask/runtime-classpath.txt");
|
||||
let contents = std::fs::read_to_string(&list_file).map_err(|e| {
|
||||
Fail::new(
|
||||
"printRuntimeClasspathJars did not produce its output file",
|
||||
&format!("{}: {e}", list_file.display()),
|
||||
"check app/shellApp/build.gradle.kts's printRuntimeClasspathJars task",
|
||||
)
|
||||
})?;
|
||||
Ok(contents
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The CA this build pins, found the same way `build-apk.sh` and
|
||||
/// `androidApp`/`shellApp`'s Gradle `generatePinnedCa` tasks do:
|
||||
/// `$AI_APP_CA`, else `$XDG_CONFIG_HOME/ai-app/certs/ca.pem`.
|
||||
fn pinned_ca_pem() -> Result<String, Fail> {
|
||||
let path = std::env::var_os("AI_APP_CA")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
let config_home = std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
PathBuf::from(std::env::var_os("HOME").unwrap()).join(".config")
|
||||
});
|
||||
config_home.join("ai-app").join("certs").join("ca.pem")
|
||||
});
|
||||
let pem = std::fs::read_to_string(&path).map_err(|e| {
|
||||
Fail::new(
|
||||
&format!("no CA certificate at {}", path.display()),
|
||||
&e.to_string(),
|
||||
"start ai-server (or app/ui-sandbox.sh) once on this machine first -- it generates the CA this build pins",
|
||||
)
|
||||
})?;
|
||||
let pem = pem.trim().to_string();
|
||||
if !pem.starts_with("-----BEGIN CERTIFICATE-----") {
|
||||
return Err(Fail::new(
|
||||
&format!("{} is not a PEM certificate", path.display()),
|
||||
"missing the BEGIN CERTIFICATE header",
|
||||
"point AI_APP_CA at a valid one",
|
||||
));
|
||||
}
|
||||
Ok(pem)
|
||||
}
|
||||
|
||||
fn compile_java(
|
||||
out_dir: &Path,
|
||||
shell_app_dir: &Path,
|
||||
sdk: &Sdk,
|
||||
ca_pem: &str,
|
||||
) -> Result<PathBuf, Fail> {
|
||||
let gen_dir = out_dir.join("generated-java");
|
||||
let package_dir = gen_dir.join("com/example/aiapp/shell");
|
||||
std::fs::create_dir_all(&package_dir).map_err(|e| {
|
||||
Fail::new(
|
||||
"could not create the generated-sources directory",
|
||||
&e.to_string(),
|
||||
"check permissions under target/",
|
||||
)
|
||||
})?;
|
||||
// Same shape as shellApp's Gradle `generatePinnedCa` task: the text
|
||||
// block must start immediately after the opening `"""`, or
|
||||
// CertificateFactory stops recognising the "-----BEGIN" preamble (a
|
||||
// real bug this project hit once -- see AGENTS.md's "Things that have
|
||||
// bitten").
|
||||
let pinned_ca_java = format!(
|
||||
"package com.example.aiapp.shell;\n\npublic final class PinnedCa {{\n private PinnedCa() {{}}\n public static final String PINNED_CA_PEM = \"\"\"\n{ca_pem}\"\"\";\n}}\n"
|
||||
);
|
||||
std::fs::write(package_dir.join("PinnedCa.java"), pinned_ca_java).map_err(|e| {
|
||||
Fail::new(
|
||||
"could not write PinnedCa.java",
|
||||
&e.to_string(),
|
||||
"check permissions under target/",
|
||||
)
|
||||
})?;
|
||||
|
||||
let classes_dir = out_dir.join("classes");
|
||||
std::fs::create_dir_all(&classes_dir).map_err(|e| {
|
||||
Fail::new(
|
||||
"could not create the classes directory",
|
||||
&e.to_string(),
|
||||
"check permissions under target/",
|
||||
)
|
||||
})?;
|
||||
|
||||
let java_dir = shell_app_dir.join("src/main/java/com/example/aiapp/shell");
|
||||
let mut cmd = Command::new("javac");
|
||||
cmd.args(["-cp"]).arg(&sdk.android_jar);
|
||||
cmd.args(["-d"]).arg(&classes_dir);
|
||||
cmd.arg(java_dir.join("MainActivity.java"));
|
||||
cmd.arg(java_dir.join("NotificationService.java"));
|
||||
cmd.arg(package_dir.join("PinnedCa.java"));
|
||||
run_checked(&mut cmd, "javac failed", "see the compiler output above")?;
|
||||
|
||||
let classes_jar = out_dir.join("classes.jar");
|
||||
let mut cmd = Command::new("jar");
|
||||
cmd.current_dir(&classes_dir);
|
||||
cmd.args(["cf"])
|
||||
.arg(&classes_jar)
|
||||
.args(["-C", "."])
|
||||
.arg(".");
|
||||
run_checked(
|
||||
&mut cmd,
|
||||
"jar failed to package the compiled classes",
|
||||
"see the output above",
|
||||
)?;
|
||||
Ok(classes_jar)
|
||||
}
|
||||
|
||||
fn dex(
|
||||
sdk: &Sdk,
|
||||
classes_jar: &Path,
|
||||
classpath_jars: &[PathBuf],
|
||||
dex_dir: &Path,
|
||||
) -> Result<(), Fail> {
|
||||
std::fs::create_dir_all(dex_dir).map_err(|e| {
|
||||
Fail::new(
|
||||
"could not create the dex output directory",
|
||||
&e.to_string(),
|
||||
"check permissions under target/",
|
||||
)
|
||||
})?;
|
||||
let mut cmd = Command::new(sdk.tool("d8"));
|
||||
cmd.args(["--release", "--min-api"])
|
||||
.arg(sdk::MIN_SDK.to_string());
|
||||
cmd.arg("--lib").arg(&sdk.android_jar);
|
||||
cmd.arg("--output").arg(dex_dir);
|
||||
cmd.arg(classes_jar);
|
||||
cmd.args(classpath_jars);
|
||||
run_checked(&mut cmd, "d8 failed", "see the compiler output above").map(|_| ())
|
||||
}
|
||||
|
||||
fn aapt2_link(sdk: &Sdk, shell_app_dir: &Path, base_apk: &Path) -> Result<(), Fail> {
|
||||
let manifest_src = shell_app_dir.join("src/main/AndroidManifest.xml");
|
||||
let manifest_text = std::fs::read_to_string(&manifest_src).map_err(|e| {
|
||||
Fail::new(
|
||||
"could not read the manifest",
|
||||
&format!("{}: {e}", manifest_src.display()),
|
||||
"check app/shellApp/src/main/AndroidManifest.xml",
|
||||
)
|
||||
})?;
|
||||
// The checked-in manifest has no `package` attribute -- Gradle injects
|
||||
// it from `android.namespace` during its own manifest merge, which
|
||||
// this pipeline does not run. aapt2 needs it to know what package to
|
||||
// generate resources under.
|
||||
if manifest_text.contains("package=") {
|
||||
return Err(Fail::new(
|
||||
"app/shellApp's manifest already has a package attribute",
|
||||
"aapt2_link() assumes it doesn't and injects one",
|
||||
"update aapt2_link() in xtask/src/apk.rs to stop injecting a second one",
|
||||
));
|
||||
}
|
||||
let merged_manifest = manifest_text.replacen(
|
||||
"<manifest ",
|
||||
&format!("<manifest package=\"{APPLICATION_ID}\" "),
|
||||
1,
|
||||
);
|
||||
let merged_manifest_path = base_apk.with_file_name("AndroidManifest.merged.xml");
|
||||
std::fs::write(&merged_manifest_path, merged_manifest).map_err(|e| {
|
||||
Fail::new(
|
||||
"could not write the merged manifest",
|
||||
&e.to_string(),
|
||||
"check permissions under target/",
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut cmd = Command::new(sdk.tool("aapt2"));
|
||||
cmd.args(["link", "-o"]).arg(base_apk);
|
||||
cmd.args(["--manifest"]).arg(&merged_manifest_path);
|
||||
cmd.arg("-I").arg(&sdk.android_jar);
|
||||
cmd.args(["--min-sdk-version", &sdk::MIN_SDK.to_string()]);
|
||||
cmd.args(["--target-sdk-version", &sdk::COMPILE_SDK.to_string()]);
|
||||
cmd.args(["--version-code", "1", "--version-name", "1.0"]);
|
||||
run_checked(&mut cmd, "aapt2 link failed", "see the output above").map(|_| ())
|
||||
}
|
||||
|
||||
fn merge(
|
||||
base_apk: &Path,
|
||||
dex_dir: &Path,
|
||||
shell_app_dir: &Path,
|
||||
abis: &[String],
|
||||
merged_apk: &Path,
|
||||
) -> Result<(), Fail> {
|
||||
std::fs::copy(base_apk, merged_apk).map_err(|e| {
|
||||
Fail::new(
|
||||
"could not copy the base APK",
|
||||
&e.to_string(),
|
||||
"check permissions under target/",
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut cmd = Command::new("jar");
|
||||
cmd.current_dir(dex_dir);
|
||||
cmd.args(["uf"])
|
||||
.arg(std::path::absolute(merged_apk).unwrap_or_else(|_| merged_apk.to_path_buf()));
|
||||
cmd.args(["classes.dex"]);
|
||||
run_checked(
|
||||
&mut cmd,
|
||||
"jar failed to add classes.dex to the APK",
|
||||
"see the output above",
|
||||
)?;
|
||||
|
||||
// Android's zip layout wants "lib/<abi>/*.so" at the archive root, but
|
||||
// cargo ndk's `-o` wrote "jniLibs/<abi>/*.so" (matching the Gradle
|
||||
// source-set layout it was pointed at) -- so this stages a "lib/"
|
||||
// directory rather than trying to rename inside the zip.
|
||||
let stage = merged_apk.with_file_name("lib-stage");
|
||||
if stage.exists() {
|
||||
std::fs::remove_dir_all(&stage).ok();
|
||||
}
|
||||
for abi in abis {
|
||||
let so_name = "libandroid_shell.so";
|
||||
let src = shell_app_dir
|
||||
.join("src/main/jniLibs")
|
||||
.join(abi)
|
||||
.join(so_name);
|
||||
if !src.is_file() {
|
||||
return Err(Fail::new(
|
||||
&format!("no native library built for {abi}"),
|
||||
&format!("expected {}", src.display()),
|
||||
"check cargo ndk's output above for that ABI",
|
||||
));
|
||||
}
|
||||
let dest_dir = stage.join("lib").join(abi);
|
||||
std::fs::create_dir_all(&dest_dir).map_err(|e| {
|
||||
Fail::new(
|
||||
"could not stage the native library",
|
||||
&e.to_string(),
|
||||
"check permissions under target/",
|
||||
)
|
||||
})?;
|
||||
std::fs::copy(&src, dest_dir.join(so_name)).map_err(|e| {
|
||||
Fail::new(
|
||||
"could not stage the native library",
|
||||
&e.to_string(),
|
||||
"check permissions under target/",
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let mut cmd = Command::new("jar");
|
||||
cmd.current_dir(&stage);
|
||||
cmd.args(["uf"])
|
||||
.arg(std::path::absolute(merged_apk).unwrap_or_else(|_| merged_apk.to_path_buf()));
|
||||
cmd.arg("lib");
|
||||
run_checked(
|
||||
&mut cmd,
|
||||
"jar failed to add the native libraries to the APK",
|
||||
"see the output above",
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
fn align_and_sign(
|
||||
sdk: &Sdk,
|
||||
merged_apk: &Path,
|
||||
final_apk: &Path,
|
||||
signer: &Signer,
|
||||
) -> Result<(), Fail> {
|
||||
let aligned_apk = merged_apk.with_file_name("aligned.apk");
|
||||
let mut cmd = Command::new(sdk.tool("zipalign"));
|
||||
cmd.args(["-f", "-p", "4"])
|
||||
.arg(merged_apk)
|
||||
.arg(&aligned_apk);
|
||||
run_checked(&mut cmd, "zipalign failed", "see the output above")?;
|
||||
|
||||
let mut cmd = Command::new(sdk.tool("apksigner"));
|
||||
cmd.args(["sign", "--ks"]).arg(&signer.keystore);
|
||||
cmd.arg("--ks-pass")
|
||||
.arg(format!("pass:{}", signer.password));
|
||||
cmd.arg("--ks-key-alias").arg(&signer.alias);
|
||||
cmd.arg("--out").arg(final_apk);
|
||||
cmd.arg(&aligned_apk);
|
||||
run_checked(
|
||||
&mut cmd,
|
||||
"apksigner failed to sign the APK",
|
||||
"see the output above",
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
fn run_checked(cmd: &mut Command, what: &str, fix: &str) -> Result<(), Fail> {
|
||||
let status = cmd.status().map_err(|e| {
|
||||
Fail::new(
|
||||
what,
|
||||
&format!("could not run {:?}: {e}", cmd.get_program()),
|
||||
fix,
|
||||
)
|
||||
})?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Fail::new(
|
||||
what,
|
||||
&format!("{:?} exited with {status}", cmd.get_program()),
|
||||
fix,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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 `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<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