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
|
another tagger stamped after import kept `untagged` for ever and its
|
||||||
folder kept asking; `updateAudioFile` promotes it now, guarded on
|
folder kept asking; `updateAudioFile` promotes it now, guarded on
|
||||||
`untagged` so a deliberate `user_skipped_permanent` survives a rescan.
|
`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
|
## CI
|
||||||
|
|
||||||
Four workflows in `.gitea/workflows/`. Three of them package and
|
Five workflows in `.gitea/workflows/`. Four of them package and
|
||||||
publish (`arch-package`, `homebrew-formula`, `index-artifact`); only
|
publish (`arch-package`, `homebrew-formula`, `index-artifact`,
|
||||||
`ci.yml` gates, and it is the one to look at when deciding whether a
|
`android-apk`); only `ci.yml` gates, and it is the one to look at when
|
||||||
push was healthy.
|
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:
|
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`.**
|
**`build/`'s platform metadata is generated from `build/config.yml`.**
|
||||||
`wails3 task common:update:build-assets` rewrites `Info.plist`, the
|
`wails3 task common:update:build-assets` rewrites `Info.plist`, the
|
||||||
`.desktop` template, `nfpm.yaml` and the Windows manifest from that
|
`.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,
|
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`)
|
nfpm's `homepage` and `license` say in place that the refresh does not
|
||||||
say so in place. That refresh also regenerates `build/ios/` and
|
own them, and **that comment is wrong**: a refresh reset them to
|
||||||
`build/android/`, which this repo does not carry: they are gitignored
|
`https://wails.io` and `MIT`. Re-check those two after any refresh.
|
||||||
rather than deleted-and-rediscovered, and their `includes:` entries
|
|
||||||
are dropped from `Taskfile.yml`. `build/config.yml`'s `version` is the
|
**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
|
*metadata* version and is not what the app reports — `main.version` is
|
||||||
stamped at link time from the packaging recipe's git-derived version.
|
stamped at link time from the packaging recipe's git-derived version.
|
||||||
|
|||||||
Reference in New Issue
Block a user