Merge remote-tracking branch 'origin/main' into wails-v3

This commit is contained in:
2026-08-17 08:29:21 -04:00
96 changed files with 11952 additions and 90 deletions
+814
View File
@@ -2394,3 +2394,817 @@ 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.
## The app starts on Android; x86_64 Android cannot run it (2026-08-16)
Two findings, and the second is the one with consequences.
**The startup bug is fixed.** `backend/system`'s `buildUserDirPath`
switched on `runtime.GOOS` and Android took the `default:` branch, so
`main()` called `os.Exit(1)` six milliseconds after the JNI bridge came
up. `main()` now calls
`system.UseHomeOverride(application.Mobile.StoragePath())` before
anything asks for a path. `StoragePath()` is `getFilesDir()` on
Android, Application Support on iOS and `""` on desktop — where
`UseHomeOverride` is a no-op — so the change needs no build tag and
alters nothing off mobile. `backend/system` gained no import of the
Wails application package, deliberately: that is the same constraint
the `indexbuild` split protects in `backend/events`.
**And then it takes SIGSYS on the x86_64 emulator.**
```
F/libc: Fatal signal 31 (SIGSYS), code 1 (SYS_SECCOMP), syscall 6
F/DEBUG: Cause: seccomp prevented call to disallowed x86_64 system call 6
```
Syscall 6 on x86_64 is `lstat`, and the caller is **not our code and
not Go's**. Go's `syscall` package already routes both `Stat` and
`Lstat` through `fstatat` on amd64 *and* arm64. The caller is
`modernc.org/libc`, which `modernc.org/sqlite` sits on and therefore
the entire database layer: `libc_linux_amd64.go`'s `Xlstat64` issues
`unix.Syscall(unix.SYS_LSTAT, …)` directly. Android's seccomp filter
forbids it because bionic never issues it.
**arm64 is unaffected, structurally rather than by luck.** arm64 has no
`lstat` syscall at all, so `ccgo_linux_arm64.go`'s `Xlstat` is
`Xfstatat(…, AT_SYMLINK_NOFOLLOW)` → `SYS_newfstatat` (79), which is
permitted. `grep -c SYS_LSTAT ccgo_linux_arm64.go` returns 0 against 1
for amd64.
Three consequences:
- **The default emulator cannot verify this app.** `make android-smoke`
on an x86_64 AVD reports a tombstone that says nothing about your
change. Verification needs an `arm64-v8a` image (full software
emulation on an x86_64 host, so slow) or a real device.
- **The x86_64 half of the fat APK is dead weight on every Android**,
not just emulators — an x86 Chromebook would hit exactly this. It is
31 MB of a 27 MB compressed artifact. Dropping it is a real option;
keeping it costs size and buys an emulator target that does not work.
Not decided here.
- The failure is at least *legible*. Unlike the `os.Exit` it replaced,
SIGSYS leaves a tombstone with a backtrace into `libwails.so`, which
is how it was identified in one pass.
Worth knowing for anything else that reaches for a pure-Go C library:
this class of bug is invisible to every build and every desktop test,
and appears only under a platform's syscall filter.
### …and the arm64 emulator is not an option on an x86_64 host
Emulator 37.1.11 refuses outright, after the 3.8 GB image download:
```
FATAL | Avd's CPU Architecture 'arm64' is not supported by the QEMU2
emulator on x86_64 host. System image must match the host
architecture.
```
Google dropped cross-architecture emulation and there is no flag for
it. So the arm64 claim above rests on reading modernc's two code paths,
not on having run it: verifying the shipped ABI needs an arm64 host, a
physical device, or `adb connect` to one. The image was deleted again;
do not re-download it.
## Android media controls need no new JNI and no new dependency (2026-08-16)
Plan 016's A4 — playback that survives the screen locking — turned out
to be reachable entirely through seams that already exist, which is the
finding worth keeping. The obvious blocker is that Wails' `androidBridge*`
helpers are unexported, so Go cannot call arbitrary Java. It does not
need to:
- **Go → Java** is `application.Android.StartForegroundService(json)`,
which *is* exported, and `build/android/` is our tree — so widening
the JSON that `WailsBridge.startForegroundService` accepts is a local
edit, not a fork of the runtime.
- **Java → Go** is `WailsBridge.emitEvent(name, json)` →
`nativeEmitEvent` → `app.Event.Emit`, which a Go `app.Event.On`
subscriber receives with `Data` as a `map[string]any`.
So the handler is one JSON document out and one command event back, and
`backend/mediacontrols`' existing `Handler`/`Callbacks` interface — written
for MPRIS — needed one addition (`OnDuck`) to cover a MediaSession.
**The Java side needs no androidx.media either.** `MediaSessionCompat`
is the documented route, but `android.media.session.MediaSession` and
`Notification.MediaStyle` are both API 21 and minSdk here is 21, so the
platform API covers it with two `Build.VERSION` branches (the channel,
and PendingIntent mutability flags) and no new Gradle dependency.
Four things measured or reasoned along the way, each of which would
have been a bug:
- **From API 26 the framework ducks the app itself** and sends no
`AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK`. So a duck implemented in the
player is a *pre-Oreo* path, and `setWillPauseWhenDucked(true)` —
which is how you get the callback back — would mean pausing for
every notification tone. Implementing both attenuates twice.
- **A duck must not touch the user's volume.** `Player.SetDuck` holds
the attenuation as a separate offset and re-applies the user's level
through `setVolumeLocked`, so it cannot accumulate across repeated
ducks and `getUserVolume` — which feeds the event, the persisted
state and every relative change — still reports what the user chose.
- **From Android 12 a background app may not *start* a foreground
service**, but it may keep delivering intents to one already running.
Every update after the first is exactly that case (a track change
with the screen off), so `WailsBridge` picks `startService` over
`startForegroundService` once `WailsForegroundService.running` is set.
- **A service started with `startForegroundService` that returns from
`onStartCommand` without calling `startForeground` is killed**, so
the transport-button intents call it too rather than only the payload
path.
**`make lint` does not see any of this.** Its three passes are the app,
`indexbuild` and `dev` tag sets, all on linux/amd64, and `android.go` is
behind the `android` build tag — the only thing that compiles it is the
cross-compiler in `make android`. That is why the payload keys, the
state words and the command names live in `androidpayload.go` *without*
a build tag, with a test: it is the half that can be checked on the
machine doing the work. A quick manual check of the tagged half is
```bash
B=$(echo /opt/android-ndk/toolchains/llvm/prebuilt/*/bin)
CC=$B/aarch64-linux-android21-clang CXX=$B/aarch64-linux-android21-clang++ \
GOOS=android GOARCH=arm64 CGO_ENABLED=1 go build ./backend/...
```
— `CXX` matters: without it the oboe C++ sources compile against the
host sysroot and fail on `android/log.h`, which reads like a missing NDK.
**None of it has run.** The APK builds for both ABIs and the Go and Java
halves compile; everything above about behaviour is read from the
Android documentation and the source. The x86_64 emulator still cannot
run this app (modernc `lstat`/seccomp, above) and an arm64 AVD still
cannot exist on an x86_64 host, so A4's first real test is a device.
## Dropping x86_64 cut the APK by 41% (measured 2026-08-16)
Plan 016's B1, decided: the ABI is gone.
| | fat (arm64 + x86_64) | arm64 only |
|---|---|---|
| `bin/yellowjacket.apk` | 27,059,130 B | 15,898,465 B |
| `lib/` entries | 2 | 1 |
It buys nothing to keep. x86_64 Android takes SIGSYS the first time it
touches the database (modernc's raw `lstat` against Android's seccomp
filter, above), which is *every* x86_64 device — emulators and x86
Chromebooks alike — not merely the emulator here.
Three places had to agree, and the third is the one that would have
made this a silent no-op: `abiFilters` in `build/android/app/
build.gradle` (what Gradle packages), `android:package` rather than
`android:package:fat` in the Makefile (what Go compiles — otherwise the
31 MB library is still built and then discarded), and the `native-code`
assertion in `android-apk.yml`'s Verify step, which is now
`native-code: 'arm64-v8a'$` and fails if a second ABI ever comes back.
The anchor is deliberate and was checked against a real artifact:
without it the pattern also matches the fat APK's line.
One consequence for the dev tier was written down before it was
checked, and checking it proved it false — see the next entry.
## arm64 translation runs Go until Go asks the CPU what it is (measured 2026-08-16)
Predicted, when the x86_64 ABI was dropped: `make android-install`
against the emulator would now fail with
`INSTALL_FAILED_NO_MATCHING_ABIS`. **Measured: it installs and
launches.** Google's `google_apis` x86_64 images carry arm64
translation —
```
ro.product.cpu.abilist = x86_64,arm64-v8a
```
— so the loader maps `lib/arm64/libwails.so` and executes it; the
tombstone confirms it with `ABI: 'x86_64'` / `Guest architecture:
'arm64'`.
It dies anyway, before a line of our code, and the instruction says
exactly why. The fault is at `libwails.so+0x15911d0`:
```
signal 4 (SIGILL), code -6 (SI_TKILL)
15911d0: d5380600 mrs x0, ID_AA64ISAR0_EL1
```
That is Go's `internal/cpu` reading the arm64 feature-ID system
register during runtime init. The translator does not implement it, so
**no Go binary starts under it** — this is not a property of this app
and no work here would change it. (`code -6 (SI_TKILL)` also means the
signal was re-raised by the process itself: Go's handler caught the
SIGILL, printed a traceback to a stdout that goes to `/dev/null`, and
re-raised. The invisible-failure rule again.)
So there are now three distinct ways this app fails on an x86_64
Android, none of them a bug in it:
| build | cause | signal |
|---|---|---|
| x86_64 | modernc's raw `lstat` vs seccomp | SIGSYS, syscall 6 |
| arm64, translated | Go reads `ID_AA64ISAR0_EL1` | SIGILL |
| arm64, real device | — | still unverified |
**A physical arm64 device is still the only verification path**, which
is the conclusion the previous session reached by a different route.
The value of this entry is that it closes the remaining plausible
shortcut, with the instruction that closes it.
### Two bugs the attempt found in the harness itself
Both were on `main`, and the first had made the whole tier unusable
since the commit that added it.
**`scripts/android-emulator.sh` did not parse.** A `case` pattern read
`*signatures do not match*)`, and `do` is a reserved word: bash fails
the parse of the *entire file*, so `make android-emulator`,
`android-install`, `android-smoke` and `android-logs` all died with
`line 190: syntax error near unexpected token 'do'`. Quoting the inner
words fixes it. A shell script that is only run interactively can carry
a syntax error indefinitely — `bash -n` in the pre-commit hook would
have caught it, and does not exist.
**A bare `adb` addresses whatever is attached.** With a second emulator
present (another project's, or a stale `offline` entry from a previous
run), every adb call fails with "more than one device", and
`cmd_install` reported that as *"no device — run 'make
android-emulator' first"* — directly after that had printed "waiting
for boot ok". `pick_device` now resolves `ANDROID_SERIAL` from
`ro.boot.qemu.avd_name`, since serials are assigned in boot order and
the AVD name is the stable identity. Verified with both emulators
running: it selects `yj-test` and installs.
## The phone shell fits, and what it cost to make it fit (2026-08-16)
Plan 016 B2, phase 1: the shell below 600px. Measured at 360×780 and
390×844 against the real app (`make dev-headless` + Playwright, which
is the tier that can answer this — server mode serves the same document
an Android WebView renders).
**What overflowed, and by how much.** The body was 652px wide in a
360px viewport before any of this. Walking every element and its shadow
roots for a `right` past the viewport named the causes in order:
| element | width | why |
|---|---|---|
| `header.top-bar` | 580 | its children's minimums, summed |
| `search-bar` | 320 | `.search-container { min-width: 200px }` |
| `job-indicator` | 157 | the label, "3 background jobs" |
A `min-width` in a flex row is a *hard* floor — it does not shrink — and
a grid item's implicit minimum is `auto`, i.e. its content. So the
header could not get smaller than the sum of what it held, the body grew
to the header, and `overflow-x: hidden` would then have hidden a third
of the app rather than fitting it. `min-width: 0` on the boxes between
the viewport and the content, plus each component standing its own
non-essential parts down in its own stylesheet, takes 360 → 360 exactly.
At 320px (400% zoom, the width WCAG 1.4.10 names) it is also exact.
**So an existing spec now asserts the opposite of what it did**, and
that is the fix landing rather than the test being weakened.
`layout-overflow.spec.ts` used to assert that the 464px of app behind
`overflow: hidden` *could be scrolled to* with a wheel gesture, which
was the remedy available when the shell had one layout. It reflows now,
which is what 1.4.10 asks for; scrolling to the overflow was the
concession.
**And a shared component brings its test handles with it.**
`bottom-nav`'s "More" opens the *existing* `<app-sidebar>` in a drawer —
the whole point being not to write a second list of destinations — but
rendering it unconditionally put a second `data-testid="nav-home"` (and
ten siblings) in the DOM. **30 existing specs failed** with "strict mode
violation: resolved to 2 elements", on a *desktop* viewport where
`bottom-nav` is `display: none` and the drawer can never open. Lazy
rendering fixes it; the component test asserts the absence, because the
failure is invisible from inside the component and appears in files
nobody touched.
Three smaller things worth keeping:
- **A new icon name is a runtime failure, not a build one.** `bars` was
not in `src/icons/names.txt`, so `offline-icons.spec.ts` caught it —
the sweep asserts `window.__yjIconMisses` is empty. `node
frontend/scripts/fetch-icons.mjs` re-vendors after adding a line.
- **A `wa-drawer` animates, so a test asserts its events**, not its
`open` property: setting `open = false` starts a hide that has not
finished on the next microtask, and a test reading the property in
between sees the state it is leaving.
- **`update(el)` in the component tier takes two arguments**
(`update(el, {})`), which is only visible from `tsc`, not from a
failing test.
### The local e2e tier was not running the same app CI runs
`requested-badge.spec.ts` failed two of three tests locally while CI was
green, and the reason is worth more than the fix: **`dev-headless.sh`
was the only place that did not neutralise `YJ_CORE_INDEX_URL`.**
`seed-sandbox.sh` and `ci.yml` both point it at `127.0.0.1:1`; the dev
launcher did not, so the app downloaded and built the real ~1M-row
Explore catalog into the run's `YJ_HOME`, and a local `make e2e` then
ran against a world CI never sees.
Found by reading the failure screenshot: the spec had searched Explore
for its fixture album and the page was full of *real* ones — Real
Estate, Arrested Youth, The Yes Album. The staged row was there and
invisible among a million others.
`dev-headless.sh` now defaults the variable to the dead address and
takes an explicit one if you want the real catalog for exploring by
hand. `make e2e` locally: 97 passed / 3 failed before, 100 passed
after.
The second half of the same problem is that **the backend is one shared
process with one database, and specs leave rows in it.**
`explore-shelves` staged its catalog only `IfEmpty`, so a single album
row left behind by `requested-badge` satisfied that gate, the shelves
were drawn from one foreign row, and the artist card the spec clicks did
not exist. It fails on the *second* local run and passes on the first,
which is the least useful order, and never in CI, where every run gets a
fresh `YJ_HOME`.
"Is the catalog empty" was the wrong question; "are my rows there" is
the right one. The staging is unconditional now (`INSERT OR IGNORE`
keyed on the MBID) and the assertion moved from *this insert wrote a
row* to *every fixture row is present* — which is both idempotent and a
stronger check, since an MBID failing `CHECK(length(mbid) = 16)` is
silently dropped by OR IGNORE and would otherwise show up as an empty
page rather than a failed setup.
**Verified: the full suite runs twice against the same app, 100 passed
both times.** That is the property to keep — a spec tier whose second
run differs from its first is a tier that will one day blame the wrong
commit.
## A media query adds no specificity, and dead CSS looks like working CSS (2026-08-16)
Plan 016 B2 phase 2 shipped the full-screen now-playing view, and
checking it with a screenshot found that **phase 1's shell rules had
never applied**.
`index.css` is base rules then component rules, and the phone block had
been inserted in the middle — above the plain `.top-bar` and `.title`
rules it meant to override. A media query is not a specificity boost,
so with equal specificity the *later* declaration wins. Measured at
390px before the fix:
| declared for the phone | actually computed |
|---|---|
| `padding-left: 0.75em` | 32px (the 2em base) |
| `gap: 0.5em` | 16px (base) |
| `font-size: 1.1em` | 24px (the 1.5em base) |
| `grid-template-columns: minmax(0,1fr) auto auto` | `320px 1fr auto` (base) |
After moving the block to the end of the file: 12px, 8px, 17.6px, and
`154px 187px 33px`.
**Nothing failed while they were dead**, which is the part worth
keeping. The phone spec asserts that the shell does not scroll
sideways, and it did not — because the fitting was being done by
`min-width: 0` and by each component's *own* media query, which live in
their own stylesheets and so had no later rule to lose to. The
declarations that did nothing were the cosmetic ones, and no assertion
was ever going to see them. A screenshot did, in about ten seconds.
The file now ends with one phone section, and says why it is last.
### What the same screenshot found about the view itself
The bottom bar was still rendering the mini player *underneath* the
full-screen view — 4em of a 844px phone spent saying exactly what the
view above it says, and invisible to every assertion about either one
(both were correct on their own). `index.css` hides `.bottom-bar` while
`#main-content[data-active-view="now-playing"]`, through `:has()`
rather than a class toggled from `index.ts`: which view is showing is
already published as an attribute, and a second expression of the same
fact is a second thing to keep in step.
That took the queue button away with it, since that button lives in the
bar — so the view carries its own, toggling the same `open` attribute
on the same panel element.
**And a css`` literal cannot contain a backtick.** A comment reading
"the track size is set on the `wa-slider` inside its shadow root"
terminates the tagged template, and the failure arrives as
`Expected "]" but found "wa"` from the CSS parser, at a line number in
the *comment*. `make css-check` exists for this and named it
immediately.
## The index artifact could not be exported, and the reason is a rule this repo already had (2026-08-16)
`maintain-index` failed on an unrelated push:
```
indexexport: copy rows: SQL logic error: no such column: total_tracks (1)
```
Three minutes in, on the one job that owns the ~205 GB checkpoint and
publishes the catalog every user downloads.
**The cause is the exception that keeps that checkpoint alive.** The
index job's `/cache` is a real `YJ_HOME` that survives between runs, so
`explore_index` there is classified `Cache` and is deliberately *not*
dropped and recreated by `cmd/indexbuild`'s schema repair
(`staleschema.go`). A column added to the schema afterwards is
therefore simply absent from that database — and `total_tracks` was
added by the album-completeness work. The exporter selected it anyway.
**The fix is the rule the importer already follows.**
`artifactHasTotals()` exists precisely because "adding a column to the
importer's SELECT is how you break every artifact already published";
the mirror image — *reading* an index older than the binary — had no
such guard. `sourceColumns()` asks
`pragma_table_info('explore_index', 'main')` and selects a literal `0`
when the column is not there, which is what the column already means by
"the catalog does not say" and what the app already renders as unknown
rather than as incomplete. The destination keeps every column, so an
importer needs no second shape.
So the pattern generalises, and is worth stating once: **any query that
crosses a version boundary in either direction asks the schema rather
than trusting it.** There are now three of these — `artifactStoresText`
(encoding), `artifactHasTotals` (import), `sourceColumns` (export).
Two things about the test are worth keeping.
It reproduces the failure **symptom first**: with the fix removed it
fails with the CI message verbatim, `copy rows: SQL logic error: no
such column: total_tracks (1)`. That was checked, not assumed.
And its first version silently proved nothing. `oldColumns` was
`strings.Replace(catalogColumns, "total_tracks, ", "", 1)` — which
matches *nothing*, because the list is formatted across lines and the
name is followed by a newline rather than a space. So the "old" index
had every current column, the probe correctly said so, and the only
reason this was caught is that the assertion about the probe ran before
the assertion about the export. A fixture built by string surgery on a
formatted constant needs to be whitespace-independent; it filters the
list now.
## Long-press is one document listener, and the header row is a row (2026-08-17)
Plan 016 B2 phase 3. A phone has no right-click, and every context menu
in this app opens from a `contextmenu` event — six components' worth,
bound three different ways (delegated on a virtualizer, per row, per
card). `frontend/src/utils/long-press.ts` is one document-capture
listener installed once from `index.ts`: a touch that holds still for
500 ms dispatches a synthetic `contextmenu` at the touch point, and
**every existing handler runs unchanged**. No component opted in, and
none can forget to.
Four things it has to get right, and each is a way the obvious version
fails:
- **The target is `composedPath()[0]`, not `elementFromPoint`**, which
stops at the outermost shadow host. Every menu here is bound inside
one, so a host-targeted event reaches a delegated listener and no
per-row one.
- **A browser that fires its own must win.** Chromium already dispatches
`contextmenu` on long-press; WebKit and the WebView vary. One arriving
during the press cancels ours; one arriving after ours is swallowed at
document capture.
- **Ours is told from theirs by identity** (a `WeakSet`), not by
`isTrusted`. `isTrusted` would work in the app and is untestable — no
test can dispatch a trusted event — so the suppression path would have
been the one thing with no coverage.
- **The click ending the gesture is swallowed**, keyed on the gesture
(cleared by the next `pointerdown`) rather than a time window, or a
quick tap on the menu that just opened is eaten too.
**What cost the time was the assertion, not the code.** The e2e spec
pressed `[role="row"]` — which is the *column header*, and it is the
first one. The gesture fired correctly, the header correctly ignored it,
and the failure looked exactly like a menu that would not open. Found by
probing the running app (`playwright-cli eval`, dispatching the same
pointer events and logging what saw the `contextmenu`), which showed the
event reaching the row's own listener with no menu behind it — i.e. the
handler was refusing it, not missing it. `.track-row` is the selector.
Verified by execution: 8 component tests (real browser, real shadow
boundary, real timings) and 2 e2e specs against the running app, twice
in a row. Not verified: any of it under a real finger on a real
WebView — the pointer events are dispatched, because neither Desktop
Chrome nor Desktop Safari has touch and there is no device tier.
## The first device run: A4 works, and two things only a phone could say (2026-08-17)
The published v1.5.0 APK, on a real phone, owner-reported. **This is the
first runtime evidence any of the Android work has ever had** — A4
shipped entirely reasoned from source.
**What holds.** Playback survives the screen locking. The MediaSession
notification appears in the status pane *with album art* — which
answers, in one observation, four of the open questions from plan 016:
the foreground service starts, POST_NOTIFICATIONS was granted and the
notification is visible, the session is picked up, and **cover art
decoded from a `MANAGE_EXTERNAL_STORAGE` path by a service is
readable**. The last was the one nobody could argue from documentation.
**Two bugs, and neither is visible from any tier we have.**
*Back did not navigate back.* The scaffold's
`MainActivity.onBackPressed` asks `webView.canGoBack()` and finishes the
activity otherwise — and this app had never touched `history`, so that
was false at every depth and back quit from anywhere. The fix is in the
frontend, not in Java: a navigation is a `history` entry now
(`recordNavigation` in `index.ts`, same URL, the destination in the
entry's state) and `popstate` replays it with `_isBack`. The Java half
needs no change, because the mechanism it already uses is the one we
were failing to feed.
Two rules keep it honest. The **first** navigation replaces the launch
entry rather than pushing one, or every launch costs a back press before
the app will close. And the in-app back buttons go through
`history.back()` rather than popping a stack of their own — `navStack`
is **deleted**, not kept alongside, because two stacks is exactly how
the detail view's own button and the phone's gesture come to disagree
about how far back one press goes. `back-navigation.spec.ts` pins that
invariant.
*The transport was off screen.* **`targetSdk 35` is Android 15, which
lays every app out edge-to-edge**, ignores the deprecated
`statusBarColor`/`navigationBarColor` the theme still sets, and hands
the app a window the size of the screen. The WebView is `match_parent`,
so the page's bottom band — the transport, and on a phone the tab bar —
was drawn underneath the gesture bar. `applyWindowInsets()` pads the
container by `systemBars | displayCutout | ime` and returns the insets
rather than consuming them. The window background goes black to match
the app's own ramp, or the padding shows as a blue-grey band.
**Neither is findable in the browser tier, and that is the lesson worth
keeping**: a viewport has no system bars, so `phone-shell.spec.ts` at
390x844 renders a shell that fits perfectly while the device cuts 48dp
off the bottom — and `page.goBack()` was never called because nothing in
a desktop shell has a back gesture. The Android tier's own note says
failure there is invisible; this is the milder version, where the app
works and is simply wrong in ways only the platform can show you.
Verified by execution: the APK builds with the Java change; 3 e2e specs
cover the history behaviour, on Chromium locally and WebKit in CI.
Not verified: the insets themselves, which need the next APK on the
owner's phone. What to look for is one thing — the transport and the tab
bar clear of the gesture bar, and the header clear of the status bar.
## The phone is a Chrome 113 WebView, and that reframes everything (2026-08-17)
The device is reachable over adb now, so the tier can be *asked* rather
than reported on. `make android-inspect` + `make android-eval` are that:
a debug build (`applicationIdSuffix ".dev"`, so it installs **beside**
the release app rather than needing the uninstall that would take the
library with it) opens `webview_devtools_remote_<pid>`, and raw CDP over
Node's built-in WebSocket evaluates in the real page. **Playwright
cannot do this** — `connectOverCDP` calls `Browser.setDownloadBehavior`
and a WebView answers "Browser context management is not supported",
killing the connection before the first evaluate.
Measured on the device (Light Phone III, TLP301):
| fact | value |
| --- | --- |
| Android | 14, SDK 34 |
| screen | 1080x1240, density 408 |
| WebView viewport | **424 x 439 CSS px**, DPR 2.55 |
| WebView engine | **Chrome 113.0.5672.136** (mid-2023) |
**The first correction: the insets commit does not explain the report.**
Edge-to-edge is forced for apps *running on* Android 15, and this phone
is Android 14 — the screenshot shows the app correctly inset, with the
status bar and the gesture bar outside it. `applyWindowInsets()` is
right and stays (the next phone, or one OS update, is Android 15), but
it is **pre-emptive, not the fix for "the controls are off screen"**.
That was an inference from a version number, and the device disagreed.
**The second correction: the black `fill` proves nothing.** A wa-icon on
the device has the right `color` (#ffd43b) and an `<svg>` in its shadow
root, and `getComputedStyle(svg).fill` is black — but that is the *svg
root*, and every vendored Font Awesome path carries
`fill="currentColor"` itself, so the root's fill is irrelevant. Measuring
the wrong node produced a diagnosis-shaped result. `__yjIconMisses` is
empty, so no name is unbundled either. Why the icons do not appear in the
screenshot is **still open**.
**What the engine version does explain, and what to check next.**
Chrome 113 has `:has()`, `color-mix()` and `dialog.showModal()`, and
lacks three things this app's dependencies use:
- **Relaxed CSS nesting** (Chrome 120): a nested rule starting with a
bare element selector is dropped. `.x { svg { ... } }` parses to
nothing; `.x { & svg { ... } }` parses. Any Web Awesome or app
stylesheet written the modern way silently loses declarations here,
and dropped declarations are exactly the failure that looks like
"rendered but wrong".
- **The Popover API** (Chrome 114). Web Awesome's popup calls
`showPopover?.()` — optional, so nothing throws — but also sets
`popover="manual"`, which on 113 is an unknown attribute doing
nothing. Every context menu, dropdown and the whole menu keyboard
model rides on that, so it is the first thing to test with a library
present.
- `light-dark()` and relative colour syntax (`rgb(from ...)`).
**The lesson for the tier: a device is an engine, not just a screen.**
Every browser tier here runs a current Chromium or WebKit, and the phone
that will actually run this app is two years behind — so "it renders at
424x439 in Chromium" (checked, the transport is on screen) says nothing
about whether it renders on the phone. The e2e tier cannot be fixed by
resizing; the missing signal is version, and CDP against the device is
the only place to get it.
Verified by execution: every number in the table, the four feature
probes, and that the hardware back button no longer kills the app (the
`.dev` build carries the history fix; pid survived a BACK press).
Unverified: what happened to the icons and the transport controls, which
is where this resumes.
@@ -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 12 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.
@@ -0,0 +1,389 @@
# 016 — What Android parity would actually take
> **Status: all of section A is done.** A1A3 landed with "let the app
> reach the user's music"; A4 (MediaSession, transport notification,
> audio focus) landed with "survive the screen locking". The direction
> taken is **option 1, the full librarian**: `MANAGE_EXTERNAL_STORAGE`
> plus an in-app folder browser, which keeps the path-keyed model
> intact. B1/B2 remain, both awaiting a decision rather than work. The
> sections below are kept as written, because they are the argument the
> decision rests on — see "What is left" at the end for the current
> state.
Plan 015 shipped a *pipeline*: the app cross-compiles, is signed and
versioned, and publishes from CI. This is the assessment of what stands
between that and an Android app worth installing.
**The headline: parity is the wrong target, and choosing it would be
the expensive mistake.** Four of the blockers below are not porting work
— they are the Android platform declining to support the model this app
is built on. The decision to make first is in "The fork in the road" at
the end; everything before it is evidence for that decision.
Severity is what the app *does* today, verified against the source and
the generated manifest, not guessed.
## A. It cannot work at all until these are fixed
### A1. The app can read no music. (deepest)
`build/android/app/src/main/AndroidManifest.xml` requests INTERNET,
VIBRATE, ACCESS_NETWORK_STATE, USE_BIOMETRIC, POST_NOTIFICATIONS, the
two location permissions, CAMERA and the two FOREGROUND_SERVICE ones.
**There is no storage or media permission of any kind.** At
`targetSdk 35` that means the app can see its own private directory and
nothing else.
Adding `READ_MEDIA_AUDIO` is necessary and *not sufficient*, because it
grants access through **MediaStore**, not through the filesystem. This
app's entire model is absolute paths: `audio_files.file_path` is the
primary key of ownership, `AddLibrary(path)` takes a directory, the
scanner walks it with `os.ReadDir`, and every one of
`GetFilePathsByAlbums` / `ByGenres` / `ByRecordingMBIDs` exists to hand
paths to the player. Scoped storage does not offer a stable directory
to walk.
The honest options are three, and they are not close in cost:
- **MediaStore as the library source.** Query the content resolver,
keep MediaStore IDs (or content URIs) beside or instead of paths, and
open audio through a `ContentResolver` file descriptor. This is the
Android-native answer and it touches the schema, the scanner, the
player's file opening and every path-keyed query.
- **`MANAGE_EXTERNAL_STORAGE`.** Keeps the path model intact and is
effectively barred from Google Play except for genuine file managers.
Viable *only* because we distribute through Obtainium — which is a
real point in its favour here, and worth stating plainly rather than
dismissing.
- **App-private storage only**, i.e. the user copies music into the
app's sandbox. Trivial to build, and nobody wants it.
### A2. The first-run flow cannot complete.
`first-run-wizard.ts` calls `DirectoryPicker()`, which is
`frontendutil.DirectoryPicker``app.Dialog.OpenFile().
CanChooseDirectories(true)`. Wails' own `ANDROID.md` lists open-directory
dialogs as **"❌ Returns an error — SAF yields tree URIs, not filesystem
paths"**. So the one action the wizard exists to perform fails, and
`<first-run-wizard>` intercepts all pointer events until a library
exists — so the app is not merely empty, it is inert.
Whatever A1 resolves to decides this: a MediaStore library needs no
picker at all, and a SAF tree needs the picker to return a URI the
backend can use.
### A3. MPRIS is compiled into the Android build.
`mpris_linux.go` is `//go:build linux`, and **`android` implies
`linux`** (documented, and the reason it is in the APK). It will look
for a session bus that does not exist. It needs `//go:build linux &&
!android`, and its Android counterpart is A4.
This one is cheap and should be done regardless — it is a two-character
build-tag change plus whatever `mediacontrols.New` returns instead.
### A4. Playback will be killed the moment the screen locks.
The scaffold's `WailsForegroundService` is typed **`dataSync`**
(`foregroundServiceType="dataSync"`, `FOREGROUND_SERVICE_TYPE_DATA_SYNC`),
and the manifest requests `FOREGROUND_SERVICE_DATA_SYNC`. A music player
needs `mediaPlayback` and `FOREGROUND_SERVICE_MEDIA_PLAYBACK`, plus a
`MediaSession` for lock-screen and notification transport controls,
plus **audio focus** — pause on a phone call, duck for a notification,
pause on headphone unplug. None of that exists today. `oto` will happily
keep writing to a stream nobody can hear.
This is the difference between "an app that plays audio" and "a music
player", and it is Java-side work in the scaffold plus a Go-side bridge.
## B. It works, but wrongly
### B1. The x86_64 half of the APK cannot run on any Android.
Established in plan 015: `modernc.org/libc`'s `Xlstat64` issues a raw
`lstat` on linux/amd64, which Android's seccomp forbids, so the process
takes `SIGSYS` the first time it touches the database. arm64 is
structurally unaffected (no `lstat` syscall exists; it routes through
`fstatat`).
So ~31 MB of the artifact is dead weight on *every* Android device,
including x86 Chromebooks. Options: drop `x86_64` from `abiFilters`
(smaller APK, no emulator target — which does not work anyway), or
carry it against a future modernc fix. **Dropping it is the honest
default**; it is also the only item in this plan that is a five-minute
change.
### B2. The UI is a desktop shell.
`MinWidth`/`MinHeight` are 800×600 and were *measured* — below ~780 the
header subtitle wraps the title out of its bar. A phone is ~360430 CSS
px wide. The sidebar collapses to icons below 900px, which is a
laptop-sized breakpoint, not a phone one. Beyond width: the app is built
on hover (the marquee's `hover` mode, tooltips), right-click context
menus, a keyboard shortcut layer with its own overlay and settings page,
multi-select with ctrl/shift, and a resizable-column track list. None of
those are gestures.
This is not a stylesheet pass. It is a second front end for the views
worth having on a phone, sharing the stores and bindings — which the
architecture supports, since a view is already a lazily-loaded chunk
behind `VIEW_LOADERS`.
### B3. Tag writing cannot reach the user's files.
`tagwriter` rewrites tags in place, and autotag's whole purpose is
applying them to a folder. Under scoped storage that is impossible
outside the sandbox without a SAF write grant per tree. If A1 lands on
MediaStore, in-place tag writing needs `MediaStore` write requests and
user confirmation per file on Android 11+.
Autotagging is arguably a desktop-only feature and saying so is a
legitimate answer.
### B4. The Explore catalog is a ~0.6 GB download into app-private storage.
It works — but with no awareness of a metered connection and no
accounting for a device where that is a meaningful fraction of free
space. At minimum it needs to be opt-in on mobile and to refuse a
metered network by default. `Android.NetworkJSON()` reports
`{connected,type}`, so the signal is available.
## C. Inert, and fine
Window geometry, menus and the system tray are documented no-ops on
mobile. The keyboard shortcut layer is harmless but its Settings page
is dead weight. `profiling` is already compiled out of production
builds. These cost nothing and need no work.
## D. Unknown until it runs on a device
**Nothing in section A or B has been observed on Android**, because the
x86_64 emulator cannot run the app (B1) and emulator 37 refuses arm64
images on an x86_64 host. Everything above is read from the source, the
generated manifest and Wails' own documentation. The first real device
run will find things this list does not have, and the most likely
places are audio latency and buffering under `oto`/oboe, and SQLite
behaviour on app-private storage.
## The fork in the road
The four blockers in section A are all the same question wearing
different clothes: **is the Android app a librarian, or a player?**
YellowJacket on the desktop is a *librarian*. It scans folders,
deduplicates covers, detects duplicate tracks, reconciles against
MusicBrainz, rewrites tags on disk, and manages downloads. That model
rests on owning a filesystem, which is precisely what Android declines
to give.
Three coherent products, and only the first is "parity":
1. **Full librarian on Android.** Requires `MANAGE_EXTERNAL_STORAGE`
(Obtainium-only distribution, which we already have), a phone UI for
every view, and media-session playback. Largest scope by far; the
result is an app almost nobody has asked for on a phone.
2. **A player for music already on the phone.** MediaStore as the
source, no scanner, no autotag, no downloads; the library, queue,
playlists, favourites and Explore-as-browsing all still make sense.
This is a genuinely good Android app and it is *not* parity — it is
a subset with a different data source.
3. **A companion to the desktop app.** The phone browses and controls
the desktop's library over the network, or syncs a subset. Smallest
Android surface, and it leans on the thing that already works.
**Option 2 is the recommendation** if the goal is an app people use;
option 3 if the goal is the least work for the most value. Option 1 is
the only one that answers "feature parity" literally, and it is the one
worth arguing hardest against.
> **Decided:** option 1's *data model* (the librarian keeps its
> filesystem and its scanner — A1 shipped that) with option 2's
> *surface*. The phone is a player over the library this app already
> builds; it does not get every view. The list is below.
## The phone gets a subset (decided)
B2 is not a stylesheet pass and not a second front end either. A view
is already a lazily-loaded chunk behind `VIEW_LOADERS` /
`DETAIL_LOADERS` in `index.ts`, and the stores and bindings are shared,
so the phone build is **a different loader table and a different
chrome**, over the same stores.
**In**, because each is something a person does with a phone in their
hand:
- **Home** — the shelves are already a phone-shaped surface.
- **Library browse** — albums, artists, genres. The grids are already
virtualized and card-shaped.
- **Now playing** — which on a phone is a *view*, not a 4em bar.
- **The queue.**
- **Search** — the header box, scoped as it already is.
- **Playlists**, including smart ones, as lists to play rather than to
edit.
**Out**, and each for a reason rather than by omission:
- **Autotag** — the review UI is a wide table and the action rewrites
files on disk; B3 has not been verified even as *possible* yet.
- **Downloads** — two tab panels of client configuration.
- **Explore** — the catalog is a ~0.6 GB download (B4); browsing it is
the last thing to earn a phone's storage.
- **Settings** — not the page. The phone needs a handful of settings
(theme, the library folder, playback) and not the 93 controls the
desktop page carries.
- **Jobs**, **shortcuts overlay**, **column configuration** — a phone
has no keyboard and no resizable columns, and the jobs indicator is
enough.
What the shell has to lose, from the audit at the top of this section:
the 800×600 minimum, the 11-item sidebar (a phone wants a bottom tab
bar over the five things above), hover as a route to anything,
right-click as the only route to a context menu (long-press is the
gesture), and ctrl/shift multi-select.
One rule for the work: **no view forks.** A phone layout that copies a
view's template is two templates to fix every bug in. Where a view
cannot serve both, the split belongs at the chunk boundary that already
exists.
Phase 1 followed that rule and found its cost: reusing `<app-sidebar>`
inside the drawer means reusing its `data-testid`s too, and a second
copy standing by in the DOM broke 30 specs that had nothing to do with
the phone. The rule holds — a second list of destinations would be
worse — but a shared component must be rendered only when it is wanted,
and the guard belongs in a test that names the reason.
## What is worth doing regardless of that decision
Cheap, independently useful, and each unblocks measurement:
1. **Drop `x86_64` from `abiFilters`** (B1) — or keep it and document
why. Five minutes.
2. **`//go:build linux && !android` on `mpris_linux.go`** (A3), so the
Android build stops carrying a D-Bus client. Small.
3. **A device smoke run**, which needs someone's phone and the published
APK. Everything in D depends on it, and it is the single highest
information-per-minute action available.
4. **Make the first-run wizard fail legibly** rather than inertly (A2)
— the picker's error already routes through `describeError`, but the
wizard still blocks pointer events, so an Android user sees a dead
screen rather than a sentence. Even under option 3 this is the right
behaviour.
## What is left (updated after A4)
**A4 is done.** `backend/mediacontrols/android.go` is a `Handler`
beside the MPRIS one, and the Java half is
`WailsForegroundService.java`: a `MediaSession`, a `MediaStyle`
transport notification and audio focus. It needed no new JNI and no new
Gradle dependency — `application.Android.StartForegroundService(json)`
going out, `WailsBridge.emitEvent` → the application event bus coming
back, and the platform `android.media.session` API rather than
androidx.media, which minSdk 21 makes available anyway.
Four decisions in it are worth keeping:
- **Ducking is a player concept, not a volume change.**
`Player.SetDuck` re-applies the *user's* level with an attenuation
offset, so `getUserVolume` still reports what the user chose and
nothing is persisted or emitted. A duck that wrote through to the
volume would let one notification tone permanently turn the music
down.
- **The duck path is pre-Oreo only.** From API 26 the framework ducks
the app itself and sends no `CAN_DUCK` focus change, so asking to be
told instead (`setWillPauseWhenDucked`) would mean pausing for every
notification tone, and doing both would attenuate twice.
- **An unchanged payload is not an event here either.** Every push
crosses JNI and re-delivers an Intent, and the player pushes state on
several paths that can agree.
- **After the first start, updates use `startService`.** From Android
12 an app in the background may not *start* a foreground service, but
it may keep delivering intents to one it already has — which is every
track change with the screen off.
The contract with Java — the payload keys, the state words, the command
names — is in `androidpayload.go`, deliberately *without* the `android`
build tag, so `go test` exercises it on every platform. Everything left
in `android.go` is untested by construction: it compiles only under a
cross-compiler and runs only on a phone.
**B1 is done: x86_64 is dropped.** 27.1 MB → 15.9 MB, measured. Three
places had to agree — `abiFilters`, the Makefile's `android:package`
(or Go still compiles a library Gradle then discards) and the
`native-code: 'arm64-v8a'$` assertion in `android-apk.yml`, whose
anchor is what stops it also matching the fat APK's line. Adding the
ABI back, if modernc ever fixes `Xlstat64`, is those same three edits.
**B2, the desktop shell.** Scope decided (below); **phases 1, 2 and 3
are done.**
- *Phase 1, the shell.* Below 600px the sidebar column is gone,
`<bottom-nav>` is the primary navigation, and the shell fits 320px
exactly — measured, from 652px in a 360px viewport before.
- *Phase 2, the full-screen now-playing view.* Where phase 1's seek bar
and volume went. A detail view, so Back pops the nav stack; it
composes the real transport components rather than copying them; and
it hides the bottom bar while it is up, so it carries its own queue
button.
- *Phase 3, long-press.* `utils/long-press.ts`: one document-capture
listener, installed once from `index.ts`, which turns a 500 ms
stationary touch into a synthetic `contextmenu` at the touch point.
Every menu in the app opens from that event, so all six components
gained the gesture without one of them changing — which is the same
argument `ContextMenuController` rests on, one layer lower. The
details that are not obvious are in `NOTES.md` (2026-08-17); the one
worth repeating is that ours is told from the browser's own
long-press event by **identity**, not `isTrusted`, because a test
cannot dispatch a trusted event and that path would otherwise be the
only uncovered one.
What is left of B2 is the track list, whose resizable columns are a
pointer feature with no touch equivalent. Not started.
**B3/B4** are unchanged, and B3 is now *possible* where it was not:
with all-files access, `tagwriter` can write in place.
### What the first device run answered (2026-08-17)
A4 **works**: playback survives the screen locking, and the transport
notification appears with cover art — which also settles the service's
access to a `MANAGE_EXTERNAL_STORAGE` path, the permission grant and
the lock-screen session in one observation. Everything below in "what
none of section A answered" was written before this and is now answered
except the OEM permission-flow variance.
It also found two faults no browser tier can see, both fixed and both
awaiting the next APK for confirmation (`NOTES.md`, same date):
- **Back quit the app from any depth.** The scaffold asks
`webView.canGoBack()`; the frontend had never used `history`. A
navigation is a history entry now, and `navStack` is gone rather than
kept beside it.
- **The transport was under the gesture bar** — or so the version
number said. `applyWindowInsets()` in `MainActivity` is right and
stays, but the phone is **Android 14**, where the system still insets
the window: the fix is pre-emptive and the symptom has another cause.
Still open, along with icons that do not appear at all. The phone's
WebView is **Chrome 113**, which is the lead (no Popover API, no
relaxed CSS nesting), and `make android-inspect` / `android-eval` are
how it gets asked.
The standing item is unchanged in kind: **B3 (tag writing) and the
permission flow still need a device**, and so does confirming these two.
### What none of section A answered
Nothing here has been observed on a device. The permission flow in
particular is the kind of thing that behaves differently across OEM
builds — `ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION` is
implemented inconsistently, which is why there is a fallback to the
global list, and neither path has been exercised.
A4 adds its own list of things only a device can answer, and they are
the likely first failures: whether the notification appears at all
(POST_NOTIFICATIONS is requested from `startForegroundService`, so a
user who declines gets a service with an invisible notification),
whether audio focus arrives while `oto`/oboe holds the output, whether
the lock screen picks up the session, and whether cover art decoded
from a `MANAGE_EXTERNAL_STORAGE` path is readable by the service.