docs: record what the Android work established and disproved
CLAUDE.md said `wails3 task common:update:build-assets` regenerates build/ios/ and build/android/. It does not: in beta.8 that command extracts only updatable_build_assets, which is darwin/ios/linux/windows, and the android tree comes from `generate build-assets`. It also said nfpm's homepage and license are left alone by the refresh -- a comment in that file says the same -- and a refresh reset them to wails.io and MIT. Both corrected, and the CI section now describes five workflows. NOTES.md gains the measurements: what cross-compiles and what does not, the emulator environment, the Wails Android documentation's own two errors, and the one line that stops the app at runtime -- buildUserDirPath switches on runtime.GOOS and Android takes the default branch returning errUnsupportedOS, so main() calls os.Exit(1) six milliseconds after the JNI bridge comes up. The fix is a documented, build-tag-free API: application.Mobile.StoragePath() returns the app's private files directory and returns "" on desktop, and resolveUserDirPath already lets YJ_HOME override the path on every OS. Deliberately not taken here -- plan 015 is a pipeline, not a port, and the larger question it does not answer is that open-directory dialogs return an error on Android while this app's entire first run is "choose your music folder".
This commit is contained in:
@@ -2394,3 +2394,196 @@ And `tag_status` was only ever written by the *insert* path, so a file
|
||||
another tagger stamped after import kept `untagged` for ever and its
|
||||
folder kept asking; `updateAudioFile` promotes it now, guarded on
|
||||
`untagged` so a deliberate `user_skipped_permanent` survives a rescan.
|
||||
|
||||
## Android cross-compiles, unchanged (measured 2026-08-16)
|
||||
|
||||
Plan 015's phase 0 gate, and it passed further than it was asked to: the
|
||||
whole app builds for Android and produces a working 27 MB fat APK with
|
||||
**no source changes at all**.
|
||||
|
||||
Environment: Arch's `android-ndk-26` (`/opt/android-ndk`, r26d /
|
||||
26.3.11579264 — the pinned version), platform `android-35` and
|
||||
build-tools 34.0.0 from `~/Android/Sdk`. Note that Arch's
|
||||
`/opt/android-sdk` carries *no* platforms, so `ANDROID_HOME` has to
|
||||
point at `~/Android/Sdk` for the Gradle half while `ANDROID_NDK_HOME`
|
||||
points at `/opt/android-ndk` for the Go half.
|
||||
|
||||
```
|
||||
export ANDROID_NDK_HOME=/opt/android-ndk
|
||||
export ANDROID_HOME="$HOME/Android/Sdk" ANDROID_SDK_ROOT="$HOME/Android/Sdk"
|
||||
cd frontend && pnpm build && cd .. # main.go embeds frontend/dist
|
||||
PATH="$PWD/scripts/toolbin:$PATH" go tool wails3 task android:package:fat
|
||||
```
|
||||
|
||||
Results, all first-try:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `libwails.so` arm64-v8a | 29.9 MB, production, stripped |
|
||||
| `libwails.so` x86_64 | 31.8 MB, production, stripped |
|
||||
| `bin/yellowjacket.apk` | 27.3 MB, both ABIs |
|
||||
| Go compile, per ABI | ~9 s |
|
||||
| Gradle assemble | ~13 s cold |
|
||||
|
||||
**The dependency that looked fatal is fine.** A `CGO_ENABLED=0` probe of
|
||||
`./backend/... ./internal/...` for `android/arm64` compiles *everything*
|
||||
except two packages, and both fail only because their Android
|
||||
implementation is cgo: `ebitengine/oto/v3` (`driver_android.go` needs its
|
||||
bundled **oboe** C++ backend) and `wails/v3/pkg/application` (the JNI
|
||||
bridge). Both are exactly what the NDK supplies. `modernc.org/sqlite` —
|
||||
the whole database layer, and the thing most likely to have no Android
|
||||
target — is clean. Confirmed in the linked object rather than inferred:
|
||||
`nm -D` shows `oto_oboe_Play` and the `oboe::` symbols, `readelf -d`
|
||||
shows `libOpenSLES.so` as NEEDED, and the
|
||||
`Java_com_wails_app_WailsBridge_native*` exports are present. The audio
|
||||
backend is genuinely linked, not stubbed.
|
||||
|
||||
Four things found on the way that are not obvious:
|
||||
|
||||
- **`wails3 update build-assets` does not generate `build/android/`.** In
|
||||
beta.8 it extracts only `internal/commands/updatable_build_assets`,
|
||||
which is darwin/ios/linux/windows. The android tree comes from
|
||||
`generate build-assets`, which extracts the *whole* asset FS and would
|
||||
rewrite all of `build/`. So it was generated into a scratch dir and
|
||||
`android/` copied across. CLAUDE.md claimed the refresh regenerates it;
|
||||
that was wrong, and is corrected.
|
||||
- **`update build-assets` does clobber nfpm's `homepage` and
|
||||
`license`**, which `build/linux/nfpm/nfpm.yaml` says in a comment it
|
||||
leaves alone. It reset them to `https://wails.io` and `MIT`. The
|
||||
comment is wrong; those two fields need re-checking after any refresh.
|
||||
- **The scaffold's `package:fat` shipped a debug arm64 library.**
|
||||
`build` forwards `ARCH` to `compile:go:shared` but not `PRODUCTION`,
|
||||
so the arm64 leg recomputed `BUILD_FLAGS` against an unset
|
||||
`.PRODUCTION` and took the debug branch — while amd64, which
|
||||
`package:fat` calls directly with `PRODUCTION: "true"`, was correct.
|
||||
A release APK therefore carried a 40 MB unstripped debug library for
|
||||
the phone ABI and a 31 MB production one for the emulator. Fixed in
|
||||
`build/android/Taskfile.yml`, which is this repo's one edit to that
|
||||
scaffold file and is commented as such. 34 MB APK before, 27 after.
|
||||
- **The generated APK is not yet an identity.** `com.wails.app`,
|
||||
`versionCode 1`, `versionName 1.0`, signed `CN=Android Debug`. That is
|
||||
plan 015 phase 2 and none of it is a surprise, but it is worth knowing
|
||||
that the scaffold happily produces an installable-once,
|
||||
never-updatable APK by default.
|
||||
|
||||
**Not established:** that it *runs*. There is no AVD or system image on
|
||||
this machine and no device attached, so nothing has launched the APK.
|
||||
Every runtime concern plan 015 lists as out of scope is still out of
|
||||
scope and still real — MPRIS in particular is compiled *in*, because
|
||||
Go's `android` GOOS implies the `linux` build tag.
|
||||
|
||||
## The Android build runs, and stops on one line (measured 2026-08-16)
|
||||
|
||||
The APK installs and launches on an emulator. `libwails.so` loads, the
|
||||
JNI bridge comes up — and the process is gone six milliseconds later.
|
||||
|
||||
**The cause is `backend/system/buildUserDirPath`.** It switches on
|
||||
`runtime.GOOS` with cases for `darwin`, `linux` and `windows` and a
|
||||
`default:` returning `errUnsupportedOS`. `runtime.GOOS` is `"android"`,
|
||||
so it takes the default, `NewYellowJacketApp` fails, and `main()` calls
|
||||
`os.Exit(1)`. `YJ_HOME` overrides that path on every OS, so an
|
||||
`android` case pointing at the app-private directory is the shape of
|
||||
the fix. It is the *first* thing that stops it, not the only one.
|
||||
|
||||
**What cost the time was not finding the bug, it was that the failure
|
||||
is invisible in all three places you would look.** Worth knowing before
|
||||
meeting it:
|
||||
|
||||
- **Go's stdout does not reach logcat.** An app's fd 1 and 2 go to
|
||||
`/dev/null`, so the `slog` line naming the error is discarded.
|
||||
`setprop log.redirect-stdio true` does not help — that redirects the
|
||||
*Java* runtime's `System.out`, not a c-shared native library's.
|
||||
- **`os.Exit` leaves no evidence.** No panic, no `AndroidRuntime`
|
||||
stack, nothing in `/data/tombstones`, nothing in `logcat -b crash` or
|
||||
dropbox. The only signal present is `Zygote: exited due to signal 9`,
|
||||
which reads as "the system killed it" and sends you looking at the
|
||||
low-memory killer.
|
||||
- **ActivityManager restarts it faster than you can observe it.**
|
||||
`pidof` always answers and `am start` always says `Status: ok`, so
|
||||
the app looks alive while crash-looping several times a second. The
|
||||
honest check is whether it is the *same pid* a few seconds later,
|
||||
which is what `make android-smoke` asserts.
|
||||
|
||||
The tell is `I/WailsBridge: Wails bridge initialized` followed
|
||||
immediately by a new pid doing the same thing.
|
||||
|
||||
**Emulator environment**, which is not the obvious one on Arch: Gradle
|
||||
needs a *platform*, and `/opt/android-sdk` (the `android-sdk` package)
|
||||
has an NDK and build-tools but an empty `platforms/`. So `ANDROID_HOME`
|
||||
points at `~/Android/Sdk` (user-owned, where sdkmanager writes) while
|
||||
`ANDROID_NDK_HOME` points at `/opt/android-ndk` — two SDKs, one for
|
||||
each half of the build. The image is
|
||||
`system-images;android-35;google_apis;x86_64` (~3.5 GB with the
|
||||
emulator sdkmanager pulls alongside it): `google_apis` rather than
|
||||
`default` because this is a WebView app and that image carries the
|
||||
Chrome-based WebView. KVM is present and usable here; without it a 30 s
|
||||
boot becomes tens of minutes, which reads as a hung target.
|
||||
|
||||
Operating all of this is `scripts/android-emulator.sh` and the
|
||||
`make android-*` targets, documented in
|
||||
`.pi/skills/yellowjacket-dev/references/android-tier.md`.
|
||||
|
||||
## What the Wails v3 Android docs say, and where they are wrong (2026-08-16)
|
||||
|
||||
Read after phase 0, before phase 2. Sources: `ANDROID.md` shipped inside
|
||||
`wails/v3@v3.0.0-beta.8` (authoritative for our exact version) and
|
||||
`v3.wails.io/guides/mobile/*`.
|
||||
|
||||
**Two claims in `ANDROID.md` are wrong for beta.8, and both were
|
||||
checked.** Its Configuration section says to put `APP_ID: com.example.
|
||||
myapp` in `build/config.yml` and that this "controls the package name".
|
||||
Neither half holds. `wails3 task` builds its variable set from CLI
|
||||
`KEY=VALUE` arguments and the Taskfile tree and **never reads
|
||||
`config.yml`** (`internal/commands/task.go`); adding `APP_ID` there and
|
||||
running `android:run:device --dry` still emits
|
||||
`am start -n com.wails.app/`. And `APP_ID` feeds only the adb commands
|
||||
in the android Taskfile — uninstall, launch, log filter — never Gradle,
|
||||
whose `applicationId` is a literal in `app/build.gradle`. So the
|
||||
identity is necessarily declared **twice** and nothing enforces
|
||||
agreement. Both are set now, each with a comment pointing at the other.
|
||||
|
||||
**The fix for the crash we found is a documented API.**
|
||||
`application.Mobile.StoragePath()` returns the app's private internal
|
||||
files directory (`getFilesDir()` on Android, Application Support on
|
||||
iOS) and — the useful part — is **build-tag-free**: `mobile.go` declares
|
||||
the interface and `mobile_stub.go` returns `""` on desktop. Since
|
||||
`resolveUserDirPath` already lets `YJ_HOME` override the path on every
|
||||
OS, the whole fix is to set that override from `StoragePath()` early in
|
||||
`main()` when it is non-empty. No `//go:build` split, no new import in
|
||||
`backend/system` (which must stay Wails-free — the `indexbuild` tag
|
||||
split exists for exactly that), and desktop behaviour is untouched
|
||||
because the stub returns empty.
|
||||
|
||||
The same section gives the general rule: branch on
|
||||
`application.System.IsMobile()` / `IsPlatform(application.PlatformAndroid)`
|
||||
rather than build tags, because it compiles everywhere.
|
||||
|
||||
**`android` implies `linux` is documented**, which confirms rather than
|
||||
discovers the MPRIS problem: `//go:build linux` files are in the Android
|
||||
build and desktop-Linux-only ones need `linux && !android`.
|
||||
|
||||
**A finding for the runtime plan, not this one: the folder picker does
|
||||
not exist on Android.** Open-*directory* dialogs "return an error — SAF
|
||||
yields tree URIs, not filesystem paths", and save-file dialogs likewise.
|
||||
This app's entire first run is "choose your music folder", and its
|
||||
library model is filesystem paths. That is a design problem, not a
|
||||
porting detail, and it is larger than the data-directory one.
|
||||
|
||||
**The scaffold ships its own android tasks**, and they are worth knowing
|
||||
before writing anything: `android:run`, `run:device`, `deploy-emulator`,
|
||||
`deploy-device`, `package`, `package:fat`, `bundle`/`bundle:fat` (AAB
|
||||
for Play), `studio`, `device:list`, `logs`, `logs:all`, `clean`, and an
|
||||
internal `ensure-emulator`. `make android-*` deliberately does not wrap
|
||||
most of them. Two reasons it does not just use `android:logs`: that task
|
||||
greps logcat for `(Wails|yellowjacket)`, which matches the `WailsBridge`
|
||||
tag but **not** the app's own process tag (`app.yellowjacket`, lowercase)
|
||||
and **not** `ActivityManager`'s "has died" line — the one that tells you
|
||||
it crashed. And `ensure-emulator` takes whatever `-list-avds | tail -1`
|
||||
returns, with no pidfile and no boot wait, so it cannot be stopped or
|
||||
sequenced by a Makefile.
|
||||
|
||||
Two smaller things. Debug builds log framework diagnostics to logcat
|
||||
under the `Wails` tag and are inspectable from `chrome://inspect`;
|
||||
production builds compile that out — so a debug APK is the more
|
||||
informative one when something is wrong. And the docs recommend
|
||||
`build-tools;35.0.0`; 34.0.0 is what is installed here and builds fine.
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
# 015 — Android release pipeline
|
||||
|
||||
Ship an Android APK from CI on every version tag, published to the Gitea
|
||||
generic package registry so Obtainium can poll a plain URL.
|
||||
|
||||
The baseline is `~/Development/ljos`, whose `.gitea/workflows/ci.yml`
|
||||
`android:` job has been through the failure modes already. Most of what
|
||||
follows is a transcription of that job onto this repo's conventions;
|
||||
where it differs, the difference is argued.
|
||||
|
||||
## What this is not
|
||||
|
||||
**This ships a pipeline, not a usable Android music player.** The
|
||||
success criterion is a signed, installable APK that launches — not an
|
||||
app anyone would want. Explicitly out of scope, and each is real:
|
||||
|
||||
- `backend/mediacontrols/mpris_linux.go` **will be compiled on Android**.
|
||||
Go's `android` GOOS implies the `linux` build tag, so the `//go:build
|
||||
linux` file is in the build and MPRIS will look for a session bus that
|
||||
does not exist. It compiles; it will error at runtime.
|
||||
- `backend/system` resolves XDG paths. Android has no XDG.
|
||||
- The explore catalog artifact is ~0.6 GB. Nothing on a phone wants that.
|
||||
- The shell is a desktop shell: an eleven-item sidebar, a 800×600
|
||||
measured minimum, a transport bar. None of that is a phone layout.
|
||||
- The library scanner walks a filesystem Android does not grant.
|
||||
|
||||
Those are the *next* plan, if there is one. Conflating them with this one
|
||||
is how a build pipeline takes six weeks.
|
||||
|
||||
## Phase 0 — the gate [DONE 2026-08-16]
|
||||
|
||||
**Passed, further than asked.** No source changes were needed; a full
|
||||
27 MB fat APK built first try, both ABIs, production-stripped. Numbers,
|
||||
the environment and four non-obvious findings are in
|
||||
`.planning/NOTES.md` — including a scaffold bug that put a *debug*
|
||||
library in the release APK's phone ABI, fixed here.
|
||||
|
||||
**It also installs and launches on an emulator, and then exits.** One
|
||||
line stops it: `backend/system/buildUserDirPath` switches on
|
||||
`runtime.GOOS` and Android takes the `default:` branch returning
|
||||
`errUnsupportedOS`, so `main()` hits `os.Exit(1)` six milliseconds
|
||||
after the JNI bridge comes up. That is the *first* thing that stops it,
|
||||
not the only one — see the "not this" section above, all of which is
|
||||
still true and still out of scope.
|
||||
|
||||
The emulator tier that found it is now part of the harness:
|
||||
`scripts/android-emulator.sh`, the `make android-*` targets, and
|
||||
`.pi/skills/yellowjacket-dev/references/android-tier.md`. It exists
|
||||
because the failure is invisible in all three places anyone would look
|
||||
(no panic, no tombstone, no crash buffer) and ActivityManager restarts
|
||||
the app fast enough that `pidof` always answers — so the tier's
|
||||
assertion is "same pid after N seconds", not "it started".
|
||||
|
||||
Original phase 0 text follows, kept because its reasoning is what the
|
||||
later phases rest on.
|
||||
|
||||
|
||||
Everything downstream is wasted if the c-shared link fails. Establish it
|
||||
by hand, locally, before writing a line of YAML.
|
||||
|
||||
Already established, by probe rather than by assumption:
|
||||
|
||||
```
|
||||
GOOS=android GOARCH=arm64 CGO_ENABLED=0 go build ./backend/... ./internal/...
|
||||
```
|
||||
|
||||
compiles the entire tree. Exactly two packages fail, and both fail only
|
||||
because their Android implementation is cgo:
|
||||
|
||||
- `ebitengine/oto/v3` — `driver_android.go` needs the bundled **oboe**
|
||||
C++ backend. Oto supports Android natively; there is no Java audio
|
||||
glue to write.
|
||||
- `wails/v3/pkg/application` — `mobile_features_android.go` needs the
|
||||
JNI bridge.
|
||||
|
||||
`modernc.org/sqlite` (the whole database layer), `beep`, `godbus` and
|
||||
every `backend/` package are clean. **No source changes are known to be
|
||||
required**, which is the single most surprising finding here and the
|
||||
reason this plan is worth doing at all.
|
||||
|
||||
What Phase 0 must actually verify:
|
||||
|
||||
1. Install NDK **r26d** (`26.3.11579264`) locally. Pinned, not "whatever
|
||||
sdkmanager gives you" — ljos's AGENTS.md records newer NDKs breaking
|
||||
this build.
|
||||
2. Generate the scaffolding (Phase 1) and run
|
||||
`wails3 task android:compile:go:shared ARCH=arm64` by hand.
|
||||
3. Confirm `build/android/app/src/main/jniLibs/arm64-v8a/libwails.so`
|
||||
exists and is an ARM64 shared object.
|
||||
4. Repeat for `amd64` (the emulator ABI).
|
||||
|
||||
**If the link fails, stop and re-plan.** The likely culprits, in order:
|
||||
alsa (oto must select oboe, not ALSA — if it reaches for `alsa.pc` the
|
||||
build tags are wrong), and `main.go`'s `//go:embed all:frontend/dist`
|
||||
combined with the generated `main_android.gen.go` overlay.
|
||||
|
||||
Deliverable: a note in `.planning/NOTES.md` recording the exact command
|
||||
and the NDK version that produced a `.so`, or the reason it cannot.
|
||||
|
||||
## Phase 1 — un-ignore and commit the Android scaffolding [DONE]
|
||||
|
||||
Done as a side-effect of phase 0, which could not run without it. One
|
||||
correction to the text below: **step 1 is wrong.** `update
|
||||
build-assets` does not generate the android tree (NOTES.md explains);
|
||||
it was generated with `generate build-assets` into a scratch dir and
|
||||
`android/` copied across. CLAUDE.md is corrected to match. Steps 2-5
|
||||
were done as written.
|
||||
|
||||
|
||||
`build/android/` is gitignored (`.gitignore:72`) and its `includes:`
|
||||
entry was dropped from `Taskfile.yml` during plan 009. That was correct
|
||||
when nothing could target Android and is what has to be undone.
|
||||
|
||||
1. `wails3 task common:update:build-assets` — beta.8 embeds
|
||||
`internal/commands/build_assets/android/`, so this generates the tree.
|
||||
2. Remove `build/android/` from `.gitignore`; add `build/ios/`'s reason
|
||||
to a comment so the asymmetry is explained rather than looking like an
|
||||
oversight.
|
||||
3. Add `android: ./build/android/Taskfile.yml` to `Taskfile.yml`'s
|
||||
`includes:`.
|
||||
4. **Gitignore the tree's own output**, or the repo grows a few hundred
|
||||
Gradle intermediates. ljos has exactly this problem — its
|
||||
`app/build/android/app/build/**` is committed. Ignore:
|
||||
- `build/android/app/build/`
|
||||
- `build/android/app/src/main/jniLibs/`
|
||||
- `build/android/overlay.json` and `build/android/gen/`
|
||||
5. `make build-prod` and `make test` still pass — the new include must
|
||||
not perturb the desktop path.
|
||||
|
||||
**The refresh hazard has to be written down.** CLAUDE.md's Packaging
|
||||
section already says `build/`'s platform metadata is regenerated from
|
||||
`build/config.yml` and hand edits are lost. Phase 2 edits `build.gradle`
|
||||
by hand. Extend that paragraph to name `build/android/app/build.gradle`
|
||||
specifically, because the loss is silent and the symptom (a debug-signed
|
||||
APK) appears months later as a failed update.
|
||||
|
||||
## Phase 2 — make the APK identifiable and updatable [DONE 2026-08-16]
|
||||
|
||||
**Narrower than planned, because beta.8's scaffold is ahead of ljos's
|
||||
beta.3: the release signing config already exists** and reads the four
|
||||
`ANDROID_KEYSTORE_*` variables with a debug-keystore fallback. So this
|
||||
phase was identity and versioning only. Verified end to end:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| package | `app.yellowjacket` (was `com.wails.app`) |
|
||||
| versionCode / versionName | `10301` / `1.3.1`, from `YJ_VERSION_CODE` / `YJ_VERSION` |
|
||||
| label | `YellowJacket` |
|
||||
| signing | throwaway keystore -> `Signer #1 DN: CN=YellowJacket Test`, not the debug key |
|
||||
| ABIs | arm64-v8a + x86_64, both production-stripped |
|
||||
|
||||
Installs and launches under the new identity. Still exits on the known
|
||||
`buildUserDirPath` bug, which is phase 0's finding and not this phase's.
|
||||
|
||||
Two things this phase learned that the text below did not know:
|
||||
|
||||
- **The identity has to be declared twice.** `applicationId` in
|
||||
`app/build.gradle` is what Gradle installs; `APP_ID` in
|
||||
`build/android/Taskfile.yml` is what every adb-driven task targets.
|
||||
`ANDROID.md` says to set `APP_ID` in `build/config.yml` — that does
|
||||
nothing in beta.8, verified with `--dry`. Both are set, each
|
||||
commented pointing at the other.
|
||||
- **The launcher activity is not under the applicationId.** It stays
|
||||
`com.wails.app.MainActivity` (the scaffold's Java package), so
|
||||
`am start -n app.yellowjacket/.MainActivity` resolves the dot against
|
||||
the wrong package and fails. `scripts/android-emulator.sh` carries the
|
||||
fully-qualified name and a comment saying why.
|
||||
|
||||
The `keytool` PKCS12 note below was confirmed verbatim: given a
|
||||
`-keypass` differing from `-storepass` it prints "Different store and
|
||||
key passwords not supported for PKCS12 KeyStores. Ignoring
|
||||
user-specified -keypass value."
|
||||
|
||||
Original phase 2 text follows.
|
||||
|
||||
|
||||
Edit `build/android/app/build.gradle`, following ljos's, whose comments
|
||||
are worth reading before writing this:
|
||||
|
||||
- `applicationId "app.yellowjacket"` — matches `config.yml`'s
|
||||
`productIdentifier`. The `namespace` stays `com.wails.app` (it is the
|
||||
Java package, not the app identity).
|
||||
- `versionCode Integer.parseInt(System.getenv("YJ_VERSION_CODE") ?: "1")`
|
||||
— **`Integer.parseInt`, not `(...) as Integer`**. Groovy binds the
|
||||
parentheses to `versionCode` first, so the cast reads as
|
||||
`versionCode("1") as Integer`, which sets a String and then casts the
|
||||
setter's null return; Gradle fails the whole project with "Value is
|
||||
null" at that line.
|
||||
- `versionName System.getenv("YJ_VERSION") ?: "0.0.0"`.
|
||||
- `abiFilters 'arm64-v8a', 'x86_64'`.
|
||||
- A `release` signing config reading `ANDROID_KEYSTORE_FILE` /
|
||||
`_PASSWORD` / `ANDROID_KEY_ALIAS` / `ANDROID_KEY_PASSWORD`, falling
|
||||
back to the debug keystore only when no keystore is supplied.
|
||||
|
||||
**Android orders releases by an integer and refuses anything not greater
|
||||
than what is installed.** A hardcoded `versionCode 1` means the first
|
||||
install is the last: every later build is rejected as a downgrade and the
|
||||
only fix is an uninstall. `1.3.1 -> 10301`, monotonic as long as minor
|
||||
and patch stay under 100.
|
||||
|
||||
**Signing is not optional past the first install.** Android refuses to
|
||||
update an app whose signing key changed, and the debug keystore differs
|
||||
between every machine and every runner — so an unsigned CI build is a
|
||||
decision to reinstall by hand forever. The job must **refuse to build**
|
||||
without the keystore rather than quietly produce an APK that can never be
|
||||
updated.
|
||||
|
||||
There is **one password and two required secrets**. keytool has defaulted
|
||||
to PKCS12 since JDK 9 regardless of the `.jks` extension, and PKCS12
|
||||
cannot hold a separate key password — given `-keypass` it warns and
|
||||
ignores it. So `ANDROID_KEY_PASSWORD` defaults to the store password and
|
||||
`ANDROID_KEY_ALIAS` to `yellowjacket`. Asking for a second password that
|
||||
cannot exist is how someone sets a wrong value and debugs Gradle at
|
||||
midnight.
|
||||
|
||||
Add `make android` → `PATH="$(TOOLBIN):$$PATH" go tool wails3 task
|
||||
android:package:fat`, beside `build-prod`. `make skill-check` fails on a
|
||||
documented target that does not exist, so document it only once it does.
|
||||
|
||||
## Phase 3 — the workflow [DONE 2026-08-16]
|
||||
|
||||
`.gitea/workflows/android-apk.yml`, plus `docs/android-release.md` as
|
||||
the operating document its error messages point at (phase 4's
|
||||
documentation half; the secrets themselves still have to be created by
|
||||
hand — see the table there).
|
||||
|
||||
Three departures from the text below, all argued in the file:
|
||||
|
||||
- **No `continue-on-error`.** The plan inherited it from ljos, where
|
||||
the Android job shares a pipeline with a server deploy that must
|
||||
never go red over a phone build. Here it is standalone and can
|
||||
neither delay nor redden anything, so a release step that fails
|
||||
silently is strictly worse than one that fails visibly.
|
||||
- **No cached `wails3` binary.** The plan budgeted for ljos's
|
||||
`tools-bin` copy. Unnecessary: the CLI is a vendored `go tool`, and
|
||||
the runner already bind-mounts `GOCACHE`/`GOMODCACHE` for every job,
|
||||
so it is warm from `ci.yml`'s own `make bindings-check`. The GTK and
|
||||
WebKit *dev* headers are still installed, because `go tool wails3`
|
||||
links them.
|
||||
- **A fourth cache volume, `/cache/gradle`.** Not in the plan and worth
|
||||
~700 MB a run.
|
||||
|
||||
Four publish-gates were added and each was checked against a real APK:
|
||||
both ABIs present, `versionCode` equal to the one derived from the tag,
|
||||
a non-empty artifact, and **not signed with the debug key** — verified
|
||||
by pointing the check at a deliberately debug-signed build, which it
|
||||
refused.
|
||||
|
||||
Rehearsed locally with the exact CI invocation
|
||||
(`make android ANDROID_SDK=... ANDROID_NDK=...`, `YJ_VERSION`,
|
||||
`YJ_VERSION_CODE`, a throwaway keystore): `app.yellowjacket`,
|
||||
versionCode 10301, versionName 1.3.1, label YellowJacket, both ABIs,
|
||||
`Signer #1 DN: CN=YellowJacket`. Not yet run on the runner.
|
||||
|
||||
Original phase 3 text follows.
|
||||
|
||||
|
||||
New file: `.gitea/workflows/android-apk.yml`. **Not a job in `ci.yml`.**
|
||||
`ci.yml` runs on every branch push and is the workflow that gates; the
|
||||
runner is capacity 1, and a 45-minute Android build in it would put every
|
||||
push behind an SDK download.
|
||||
|
||||
```yaml
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
```
|
||||
|
||||
This is where the baseline genuinely diverges. ljos computes its version
|
||||
in CI (`scripts/next-version.sh`) and gates the Android job on
|
||||
`needs.release.outputs.version != ''`, with an `always()` whose absence
|
||||
would silently kill the manual path. **This repo has no release
|
||||
automation** — tags are pushed by hand and `homebrew-formula.yml` already
|
||||
keys on `v*`. So there is no `needs:`, no `always()`, and no status
|
||||
function to get wrong: the tag *is* the version, and a dispatch falls
|
||||
back to `git describe --tags --abbrev=0`.
|
||||
|
||||
Container, matching `ci.yml`'s conventions (`ubuntu:24.04`, clone by hand
|
||||
with `PACKAGE_TOKEN` rather than `actions/checkout`, which is a JS action
|
||||
needing node before any step has installed it):
|
||||
|
||||
```yaml
|
||||
container:
|
||||
image: ubuntu:24.04
|
||||
volumes:
|
||||
- /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool
|
||||
- /home/logan/docker/gitea/data/runner/cache/android-sdk:/cache/android-sdk
|
||||
```
|
||||
|
||||
The SDK path must be inside the runner's `valid_volumes` allowlist —
|
||||
a directory outside it makes the job **fail to start**, not silently skip
|
||||
the mount. `/cache/tool` is already allowed and already holds the Go
|
||||
toolchain `ci.yml` downloads.
|
||||
|
||||
`continue-on-error: true` and `timeout-minutes: 45`. Advisory, because a
|
||||
tag's other three workflows must not go red over a phone build, and a
|
||||
backstop because a wedged SDK download must not hold the only runner slot
|
||||
for hours.
|
||||
|
||||
Steps:
|
||||
|
||||
1. **System packages.** `ci.yml`'s set plus `unzip` and `openjdk-17-jdk`.
|
||||
`libasound2-dev` stays — it is for the *host* `wails3` build, not the
|
||||
Android cross-build, which uses oboe.
|
||||
2. **Go toolchain** — reuse `ci.yml`'s `/cache/tool/go` block verbatim.
|
||||
3. **Android SDK and NDK (cached).** ljos's `install_if_missing`
|
||||
idempotent guard, unchanged: cmdline-tools 11076708, `platform-tools`,
|
||||
`platforms;android-34`, `build-tools;34.0.0`, `ndk;26.3.11579264`.
|
||||
sdkmanager is itself idempotent but still spends minutes verifying,
|
||||
which is why the explicit directory guards are there. ~3 GB and most of
|
||||
the job's wall clock on the first run; a directory listing after.
|
||||
4. **wails3.** Cheaper here than in ljos, which pins
|
||||
`go install …/wails3@$version` against `app/go.mod`. This repo vendors
|
||||
the CLI (`go tool wails3`, `scripts/toolbin/wails3`), so the version is
|
||||
already pinned by `go.mod` and there is nothing to drift. It still
|
||||
*links* GTK and WebKit, so cache the built binary in
|
||||
`/cache/android-sdk/tools-bin` keyed on the wails version — and note
|
||||
ljos's finding that **caching the binary alone turned a slow job into
|
||||
a broken one**: `wails3` is dynamically linked, so the runtime
|
||||
packages are needed even on a cache hit. Here they are already in
|
||||
step 1.
|
||||
5. **Frontend + codegen.** `pnpm install --frozen-lockfile && pnpm build`
|
||||
(pnpm, not ljos's npm), then `make generate`. `main.go` embeds
|
||||
`frontend/dist`, so nothing Go-side typechecks without it.
|
||||
6. **Decode the keystore.** Refuse to build if `ANDROID_KEYSTORE_B64` is
|
||||
unset, with the sentence explaining why (Phase 2). Decide the absolute
|
||||
path *here* and export it via `$GITHUB_ENV` — **`${{ env.HOME }}`
|
||||
evaluates to an empty string in Gitea's expression context**, which
|
||||
turned `$HOME/x.jks` into `/x.jks` and surfaced as a missing file
|
||||
fifty-five seconds into a Gradle run.
|
||||
7. **Build.** Compute `YJ_VERSION_CODE` from the tag, verify the keystore
|
||||
opens with `keytool -list` *before* Gradle does (Gradle only notices at
|
||||
`:app:validateSigningRelease`, a minute in, and reports it as a missing
|
||||
file), then `make android`.
|
||||
8. **Verify the signature.** `apksigner verify --print-certs`, and print
|
||||
the SHA-256 with the note that a change to it breaks every future
|
||||
update. **Nothing here pipes into `head`**: under `set -o pipefail`,
|
||||
`head -1` exits early, the producer takes SIGPIPE, and the step fails
|
||||
with 141 *after* printing a perfectly good APK. Use `find … -print
|
||||
-quit` and a captured variable.
|
||||
9. **Publish** to `api/packages/${OWNER}/generic/yellowjacket-android`,
|
||||
authenticating `--user "${OWNER}:${PACKAGE_TOKEN}"` — the same
|
||||
credential pair `arch-package.yml` already uses, not ljos's
|
||||
`REGISTRY_USER`/`REGISTRY_TOKEN`. Two copies: a versioned one for
|
||||
history and a fixed `latest/yellowjacket.apk` that Obtainium watches.
|
||||
Gitea refuses to overwrite, so delete `latest` first. The generic
|
||||
registry is readable **without credentials**, which is what lets
|
||||
Obtainium poll a plain URL with no token and no public source mirror.
|
||||
|
||||
## Phase 4 — secrets and documentation
|
||||
|
||||
Secrets to create on the repo (all under Settings → Actions → Secrets):
|
||||
|
||||
| Secret | Required | Note |
|
||||
|---|---|---|
|
||||
| `ANDROID_KEYSTORE_B64` | yes | `base64 -w0 yellowjacket-release.jks` |
|
||||
| `ANDROID_KEYSTORE_PASSWORD` | yes | |
|
||||
| `ANDROID_KEY_ALIAS` | no | defaults to `yellowjacket` |
|
||||
| `ANDROID_KEY_PASSWORD` | no | defaults to the store password |
|
||||
| `PACKAGE_TOKEN` | already exists | used by `arch-package.yml` |
|
||||
|
||||
Write the keytool command, the Obtainium URL and the signing-key warning
|
||||
into a docs page — this is the part of ljos's setup that lives in
|
||||
`docs/clients.md` and is referenced from the workflow's error messages,
|
||||
so the messages have somewhere to point.
|
||||
|
||||
Then extend CLAUDE.md's CI section: it currently says "four workflows,
|
||||
three of them package and publish; only `ci.yml` gates". That becomes
|
||||
five, with the same sentence still true.
|
||||
|
||||
## Order and stopping points
|
||||
|
||||
Phase 0 gates everything. Phases 1–2 are one commit's worth of work and
|
||||
are verifiable locally without CI. Phase 3 is the only part that needs a
|
||||
runner, and its first run will be slow and will probably fail once on
|
||||
something in the SDK step — budget for that rather than treating it as a
|
||||
setback.
|
||||
|
||||
**Stop after Phase 0 if the c-shared link does not work.** Every later
|
||||
phase is scaffolding for a build that does not exist, and the honest
|
||||
outcome is a NOTES.md entry saying which package cannot cross-compile and
|
||||
what it would take.
|
||||
@@ -1768,10 +1768,24 @@ Feature branches and PRs are the norm, but direct pushes to `main` are allowed.
|
||||
|
||||
## CI
|
||||
|
||||
Four workflows in `.gitea/workflows/`. Three of them package and
|
||||
publish (`arch-package`, `homebrew-formula`, `index-artifact`); only
|
||||
`ci.yml` gates, and it is the one to look at when deciding whether a
|
||||
push was healthy.
|
||||
Five workflows in `.gitea/workflows/`. Four of them package and
|
||||
publish (`arch-package`, `homebrew-formula`, `index-artifact`,
|
||||
`android-apk`); only `ci.yml` gates, and it is the one to look at when
|
||||
deciding whether a push was healthy.
|
||||
|
||||
**`android-apk.yml` is the only one keyed on a tag and the only one
|
||||
that can lose something irrecoverable.** It builds the signed fat APK
|
||||
on every `v*` tag and publishes it to the *generic* registry, which is
|
||||
readable without credentials — the reason Obtainium can poll a plain
|
||||
URL. Android refuses to update an app whose signing certificate
|
||||
changed, and the only remedy is an uninstall that takes the user's
|
||||
library with it, so the job **refuses to build** without the keystore
|
||||
secret rather than falling through to Gradle's debug-key default, and
|
||||
**refuses to publish** an artifact whose certificate says `CN=Android
|
||||
Debug`. It is deliberately not a job in `ci.yml`: that workflow runs on
|
||||
every branch push, this one takes tens of minutes on a cold cache, and
|
||||
the runner has capacity 1. `docs/android-release.md` is the operating
|
||||
document.
|
||||
|
||||
Two jobs, both in an `ubuntu:24.04` container:
|
||||
|
||||
@@ -1853,11 +1867,20 @@ four bit the packaging recipes:
|
||||
**`build/`'s platform metadata is generated from `build/config.yml`.**
|
||||
`wails3 task common:update:build-assets` rewrites `Info.plist`, the
|
||||
`.desktop` template, `nfpm.yaml` and the Windows manifest from that
|
||||
one file — so a hand edit to any of them is lost on the next refresh,
|
||||
and the two fields it does *not* own (nfpm's `homepage` and `license`)
|
||||
say so in place. That refresh also regenerates `build/ios/` and
|
||||
`build/android/`, which this repo does not carry: they are gitignored
|
||||
rather than deleted-and-rediscovered, and their `includes:` entries
|
||||
are dropped from `Taskfile.yml`. `build/config.yml`'s `version` is the
|
||||
one file — so a hand edit to any of them is lost on the next refresh.
|
||||
nfpm's `homepage` and `license` say in place that the refresh does not
|
||||
own them, and **that comment is wrong**: a refresh reset them to
|
||||
`https://wails.io` and `MIT`. Re-check those two after any refresh.
|
||||
|
||||
**That refresh does not touch the mobile trees**, contrary to what this
|
||||
file said for five phases. `update build-assets` extracts only
|
||||
`updatable_build_assets` (darwin/ios/linux/windows); `build/android/`
|
||||
and `build/ios/` come from `generate build-assets`, which rewrites the
|
||||
whole of `build/`. So `build/android/` is **committed and hand-edited
|
||||
like source** — it was generated once into a scratch directory and
|
||||
copied across (plan 015), it carries one deliberate edit to its
|
||||
`Taskfile.yml`, and only its output is gitignored. `build/ios/` is
|
||||
still not carried and its `includes:` entry is still dropped.
|
||||
`build/config.yml`'s `version` is the
|
||||
*metadata* version and is not what the app reports — `main.version` is
|
||||
stamped at link time from the packaging recipe's git-derived version.
|
||||
|
||||
Reference in New Issue
Block a user