Build the Android APK on main and serve it for phone install #34

Open
opened 2026-08-10 12:26:37 +00:00 by jeroen · 1 comment
Owner

Problem

The Android app has been buildable since 2026-08-08 and installable by nobody.
crates/viberfox_android/build-apk.sh needs an NDK, an SDK root, a JDK and
cargo-ndk; none of those are on a phone, and docs/notes/android-build.md
recorded a deliberate decision to have no APK job (the signing key would have
to be a real secret to be worth anything). So the artifact existed and the
install path did not — the only way to get the app onto a device was to run the
script by hand on a machine that had all four, then reach the phone somehow.

Two facts make the "no APK job" reasoning weaker than it looked:

  • The certificate needs to be stable, not secret. Android keys an installed
    app to the certificate that signed it and refuses an update signed by another
    (INSTALL_FAILED_UPDATE_INCOMPATIBLE). A key regenerated per CI run — which is
    what build-apk.sh:159's built-in debug keystore is in a fresh container —
    turns every deploy into an uninstall/reinstall.
  • Serving it costs nothing new. deploy-web.yml already publishes into
    /data/viberfox/site, which Caddy has mounted and file_server'd.

Approach

A new .forgejo/workflows/deploy-android.yml, shaped like deploy-web.yml and
separate from ci.yml for the same reason: this one links the whole Bevy stack
against a runner with capacity 2, and the android lane in ci.yml must stay a
fast cargo check on every push.

  • Trigger: push to main, plus workflow_dispatch.
  • Publishes to /data/viberfox/site/apk/https://viberfox.garage44.eu/apk/,
    with a small landing page (commit, date, size, sha256, download button).
  • Fixed filenames, so a build overwrites its predecessor and the directory
    stays at exactly one APK. That is the whole retention policy: the runner host
    is at 76% with a 24 GB Actions cache on it.
  • deploy-web.yml's rsync --delete grows --exclude '/apk/'; without it the
    next web deploy silently removes the APK.
  • build-apk.sh learns ANDROID_KEY/ANDROID_CERT (apksigner --key/--cert)
    and strips the packaged .so copy with llvm-strip --strip-unneeded.
  • Host: a stable self-signed identity at /data/viberfox/android-key/
    (.pk8 + .x509.pem — openssl can produce that pair and keytool cannot,
    which keeps a JDK off the host), mounted read-only into the job, plus that
    path in the runner's valid_volumes and a no-cache + APK content-type header
    in the Caddyfile.

Acceptance criteria

  • deploy-android.yml runs on pushes to main and on manual dispatch.
  • The published directory holds one APK, one .sha256 and one index.html,
    under names that do not vary between builds.
  • A web deploy no longer deletes the APK.
  • The signing certificate is stable across builds, so a new build installs
    over the previous one without an uninstall.
  • The job re-asserts, from the packaged archive, that android_main is an
    exported dynamic symbol, that there is no classes.dex, and that nothing
    needs libc++_shared.so — the strip is the step that would otherwise
    produce an APK that builds, signs, installs and does not start.
  • A missing signing mount fails with a sentence, not an apksigner stack
    trace (the runner drops an unlisted volume with a warning in its log and
    nothing in the job's).
  • First run is green and the APK appears at the URL. Never executed —
    the packaging path is only exercised by the job itself.
  • The APK installs and starts on a real phone. Still open from the port
    itself: nothing has ever run on a device (docs/notes/android-build.md,
    "Verification status").

Verification

bash -n crates/viberfox_android/build-apk.sh, plus each run: block extracted
from the workflow YAML and syntax-checked — both done. The publish step was
dry-run against a temp root, so the landing page and the atomic rename are
exercised; curl -sI https://viberfox.garage44.eu/apk/ confirms the Caddy
headers on a placeholder page.

Everything past that needs the job: merge to main (or dispatch on the branch)
and watch the run. It cannot be verified in the agent container, which has no
NDK, no SDK and no JDK — and a full android-release link is ~28 minutes cold.

Device install is a workstation/phone check, not a CI one.

Out of scope

  • GameActivity and a Gradle project. Still NativeActivity, so text fields on
    the phone remain unusable — that trade is unchanged and documented.
  • A release story. No versioned APKs, no tags, no versionCode bump per
    build; AndroidManifest.xml stays at versionCode="1".
  • Other ABIs. arm64-v8a only; no x86_64 emulator build in CI.
  • Play Store anything. This is a self-signed identity for side-loading.
  • Baking the SDK/JDK into viberfox-ci:latest. The better end state, same as
    for the NDK, but that image lives in another repo.

Branch: feat/34-build-android-apk-main

## Problem The Android app has been buildable since 2026-08-08 and installable by nobody. `crates/viberfox_android/build-apk.sh` needs an NDK, an SDK root, a JDK and `cargo-ndk`; none of those are on a phone, and `docs/notes/android-build.md` recorded a deliberate decision to have **no APK job** (the signing key would have to be a real secret to be worth anything). So the artifact existed and the install path did not — the only way to get the app onto a device was to run the script by hand on a machine that had all four, then reach the phone somehow. Two facts make the "no APK job" reasoning weaker than it looked: * The certificate needs to be **stable**, not secret. Android keys an installed app to the certificate that signed it and refuses an update signed by another (`INSTALL_FAILED_UPDATE_INCOMPATIBLE`). A key regenerated per CI run — which is what `build-apk.sh:159`'s built-in debug keystore is in a fresh container — turns every deploy into an uninstall/reinstall. * Serving it costs nothing new. `deploy-web.yml` already publishes into `/data/viberfox/site`, which Caddy has mounted and `file_server`'d. ## Approach A new `.forgejo/workflows/deploy-android.yml`, shaped like `deploy-web.yml` and separate from `ci.yml` for the same reason: this one *links* the whole Bevy stack against a runner with capacity 2, and the android lane in `ci.yml` must stay a fast `cargo check` on every push. * Trigger: push to `main`, plus `workflow_dispatch`. * Publishes to `/data/viberfox/site/apk/` → https://viberfox.garage44.eu/apk/, with a small landing page (commit, date, size, sha256, download button). * **Fixed filenames**, so a build overwrites its predecessor and the directory stays at exactly one APK. That is the whole retention policy: the runner host is at 76% with a 24 GB Actions cache on it. * `deploy-web.yml`'s `rsync --delete` grows `--exclude '/apk/'`; without it the next web deploy silently removes the APK. * `build-apk.sh` learns `ANDROID_KEY`/`ANDROID_CERT` (apksigner `--key/--cert`) and strips the packaged `.so` copy with `llvm-strip --strip-unneeded`. * Host: a stable self-signed identity at `/data/viberfox/android-key/` (`.pk8` + `.x509.pem` — openssl can produce that pair and `keytool` cannot, which keeps a JDK off the host), mounted read-only into the job, plus that path in the runner's `valid_volumes` and a `no-cache` + APK content-type header in the Caddyfile. ## Acceptance criteria - [x] `deploy-android.yml` runs on pushes to `main` and on manual dispatch. - [x] The published directory holds one APK, one `.sha256` and one `index.html`, under names that do not vary between builds. - [x] A web deploy no longer deletes the APK. - [x] The signing certificate is stable across builds, so a new build installs over the previous one without an uninstall. - [x] The job re-asserts, from the packaged archive, that `android_main` is an exported dynamic symbol, that there is no `classes.dex`, and that nothing needs `libc++_shared.so` — the strip is the step that would otherwise produce an APK that builds, signs, installs and does not start. - [x] A missing signing mount fails with a sentence, not an apksigner stack trace (the runner drops an unlisted volume with a warning in *its* log and nothing in the job's). - [ ] **First run is green** and the APK appears at the URL. Never executed — the packaging path is only exercised by the job itself. - [ ] The APK installs and starts on a real phone. Still open from the port itself: nothing has ever run on a device (`docs/notes/android-build.md`, "Verification status"). ## Verification `bash -n crates/viberfox_android/build-apk.sh`, plus each `run:` block extracted from the workflow YAML and syntax-checked — both done. The publish step was dry-run against a temp root, so the landing page and the atomic rename are exercised; `curl -sI https://viberfox.garage44.eu/apk/` confirms the Caddy headers on a placeholder page. Everything past that needs the job: merge to `main` (or dispatch on the branch) and watch the run. It cannot be verified in the agent container, which has no NDK, no SDK and no JDK — and a full `android-release` link is ~28 minutes cold. Device install is a workstation/phone check, not a CI one. ## Out of scope * **GameActivity and a Gradle project.** Still NativeActivity, so text fields on the phone remain unusable — that trade is unchanged and documented. * **A release story.** No versioned APKs, no tags, no `versionCode` bump per build; `AndroidManifest.xml` stays at `versionCode="1"`. * **Other ABIs.** `arm64-v8a` only; no `x86_64` emulator build in CI. * **Play Store anything.** This is a self-signed identity for side-loading. * **Baking the SDK/JDK into `viberfox-ci:latest`.** The better end state, same as for the NDK, but that image lives in another repo. --- Branch: `feat/34-build-android-apk-main`
Author
Owner

Picked up as part of #44, which found why this never worked: CI ran cargo fmt --check as the first step of the check job, so an unformatted merge ended the job before the Android lane ran — and behind it sat a real compile break (the APK is built --no-default-features, which drops solo, which makes two session.rs enum variants dead, and unused = "deny" makes dead code an error). deploy-android.yml has failed on every push to main since it landed.

Both are fixed on feat/44-native-release-polish, and cargo check -p viberfox_android --target aarch64-linux-android now passes for the first time. Leaving this open until the deploy actually runs on main and https://viberfox.garage44.eu/apk/ serves a verified 200.

Picked up as part of #44, which found why this never worked: CI ran `cargo fmt --check` as the first step of the `check` job, so an unformatted merge ended the job before the Android lane ran — and behind it sat a real compile break (the APK is built `--no-default-features`, which drops `solo`, which makes two `session.rs` enum variants dead, and `unused = "deny"` makes dead code an error). `deploy-android.yml` has failed on every push to `main` since it landed. Both are fixed on `feat/44-native-release-polish`, and `cargo check -p viberfox_android --target aarch64-linux-android` now passes for the first time. Leaving this open until the deploy actually runs on `main` and `https://viberfox.garage44.eu/apk/` serves a verified 200.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jeroen/cartopolis#34
No description provided.