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:
irisandClaude Sonnet committed 2026-09-05 07:11:44 -04:00
1 parent 32a5256a0d
commit ceabd00805
11 files changed
+1289 -25

No files matched your search

+205 -25
View File
@@ -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