docs: record what the Android work established and disproved
Section A of plan 016 is closed and B1 is decided, so the three tenses move together: CLAUDE.md for what mediacontrols now is, the skill for what to run, NOTES.md for what was measured and when. The entry worth reading is the one that disproves a claim written here earlier in the same session. Dropping x86_64 was expected to make make android-install fail with INSTALL_FAILED_NO_MATCHING_ABIS. Measured, it installs and launches: Google's google_apis x86_64 images carry arm64 translation (abilist = x86_64,arm64-v8a), so the loader maps lib/arm64/libwails.so and runs it. It dies before any of our code with SIGILL, and the disassembly names the reason exactly -- `mrs x0, ID_AA64ISAR0_EL1`, Go's internal/cpu reading the arm64 feature register at runtime init, which the translator does not implement. So no Go binary starts under it, and that is not a property of this app. Which closes the last plausible shortcut. There are now three distinct ways this app fails on an x86_64 Android -- seccomp on the x86_64 build, an unimplemented system register on the translated arm64 one, and a real device still unverified -- and none of them is a bug in it. A phone remains the only verification path. Plan 016 also carries the B2 scope, now decided rather than recommended: option 1's data model with option 2's surface. The phone gets home, library browse, now-playing-as-a-view, the queue, search and playlists; it does not get autotag, downloads, Explore or the 93-control Settings page, and each of those has a reason written beside it. One rule for the work: no view forks, because a phone template that copies a view's is two templates to fix every bug in.
This commit is contained in:
@@ -2659,3 +2659,172 @@ 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.
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# 016 — What Android parity would actually take
|
||||
|
||||
> **Status: A1, A2 and A3 are done** (commit "let the app reach the
|
||||
> user's music"). 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. A4 (MediaSession and audio
|
||||
> focus) and B1/B2 remain. 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.
|
||||
> **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
|
||||
@@ -194,6 +196,56 @@ 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.
|
||||
|
||||
## What is worth doing regardless of that decision
|
||||
|
||||
Cheap, independently useful, and each unblocks measurement:
|
||||
@@ -212,37 +264,68 @@ Cheap, independently useful, and each unblocks measurement:
|
||||
behaviour.
|
||||
|
||||
|
||||
## What is left (updated after A1-A3)
|
||||
## What is left (updated after A4)
|
||||
|
||||
**A4, playback that survives the screen locking.** The manifest and the
|
||||
service are typed `mediaPlayback` now and the permission is declared,
|
||||
so the foundation is in place; what is missing is a `MediaSession`, a
|
||||
transport notification and audio-focus handling. The plumbing for it
|
||||
exists and needs no new JNI: Go can call
|
||||
`application.Android.StartForegroundService(json)` (exported by Wails),
|
||||
and Java can call `WailsBridge.emitEvent(name, json)` back into the
|
||||
application event bus, which Go subscribes to. So the shape is a JSON
|
||||
payload of title/artist/state going out and transport commands coming
|
||||
back, with `backend/mediacontrols` gaining an Android handler beside
|
||||
the MPRIS one — the interface it already defines is the right shape.
|
||||
**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.
|
||||
|
||||
Audio focus is the half that is easy to forget and the more important
|
||||
one: pause on a phone call, duck for a notification, pause on headphone
|
||||
unplug. `oto` will happily keep writing to a stream nobody can hear.
|
||||
Four decisions in it are worth keeping:
|
||||
|
||||
**B1, the x86_64 half of the APK**, which cannot run on any Android
|
||||
because of the modernc `lstat` seccomp trap. Still undecided; dropping
|
||||
it is a five-minute change that halves the artifact.
|
||||
- **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.
|
||||
|
||||
**B2, the desktop shell.** Untouched and the largest remaining piece.
|
||||
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.** The largest remaining piece, and the scope
|
||||
is now decided — see "The phone gets a subset" below.
|
||||
|
||||
**B3/B4** are unchanged, and B3 is now *possible* where it was not:
|
||||
with all-files access, `tagwriter` can write in place.
|
||||
|
||||
### What A1-A3 did not answer
|
||||
### 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