Merge remote-tracking branch 'origin/main' into wails-v3
This commit is contained in:
@@ -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 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.
|
||||
@@ -0,0 +1,389 @@
|
||||
# 016 — What Android parity would actually take
|
||||
|
||||
> **Status: all of section A is done.** A1–A3 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 ~360–430 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.
|
||||
Reference in New Issue
Block a user