From df2e9ea777d11e4f92df0b621a512f2f96332a8d Mon Sep 17 00:00:00 2001 From: Logan Date: Sun, 16 Aug 2026 22:26:39 -0400 Subject: [PATCH] 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. --- .../references/android-tier.md | 106 +++++++++-- .planning/NOTES.md | 169 ++++++++++++++++++ .../pending/016-android-feature-parity.md | 137 +++++++++++--- CLAUDE.md | 26 ++- docs/android-release.md | 12 +- 5 files changed, 404 insertions(+), 46 deletions(-) diff --git a/.pi/skills/yellowjacket-dev/references/android-tier.md b/.pi/skills/yellowjacket-dev/references/android-tier.md index 3bc4780..0b5e99f 100644 --- a/.pi/skills/yellowjacket-dev/references/android-tier.md +++ b/.pi/skills/yellowjacket-dev/references/android-tier.md @@ -47,7 +47,7 @@ make android-setup # SDK pieces + the yj-test AVD, idempotent Then: ```bash -make android # fat APK (arm64 + x86_64) -> bin/yellowjacket.apk +make android # arm64-v8a APK -> bin/yellowjacket.apk (~16 MB) make android-emulator # boot headless in the background, wait for boot make android-install # adb install -r make android-smoke # launch, then assert the same pid survives 10s @@ -63,6 +63,17 @@ command line and kills it, silently dropping the rest of your compound command. The emulator is addressed by its saved pid in `.dev/emulator.pid`, same discipline as `make dev-stop`. +**adb is addressed by AVD name, not by whatever is plugged in.** The +script resolves `ANDROID_SERIAL` from `ro.boot.qemu.avd_name` before +any device command, because a second emulator (another project's, or +this one's own corpse left `offline` by a previous run) makes a bare +`adb` fail with "more than one device" — which `cmd_install` reported +as *"no device — run 'make android-emulator' first"* immediately after +that had succeeded. Serials are assigned in boot order and change +between runs, so the AVD name is the identity. Set `ANDROID_SERIAL` +yourself and it is honoured; one device that is not ours (a phone) is +taken as the target. + ## Things that cost a cycle - **`ANDROID_HOME` must carry a platform, and Arch's does not.** @@ -135,11 +146,57 @@ FATAL | Avd's CPU Architecture 'arm64' is not supported by the QEMU2 Google dropped cross-architecture emulation; there is no flag. The options are an arm64 host, a physical device, or `adb connect` to one. -Two consequences worth holding onto. The x86_64 half of the fat APK is -*only* useful for emulators, and cannot work on any Android until -modernc fixes this — including x86 Chromebooks. And the tombstone is at -least honest: unlike the `os.Exit` that came before it, this one leaves -a real crash record with a backtrace. +**The x86_64 ABI is therefore gone from the build** (`abiFilters` in +`build/android/app/build.gradle`, `android:package` rather than +`package:fat` in the Makefile, and a `native-code: 'arm64-v8a'$` +assertion in `android-apk.yml` that fails if it comes back). It could +not run on any Android until modernc fixes this — x86 Chromebooks +included — and dropping it took the artifact from 27 MB to 15.9 MB. +The tombstone was at least honest while it lasted: unlike the +`os.Exit` that came before it, it left a real crash record with a +backtrace. + +### The emulator still installs it, and it still does not run + +The obvious guess about dropping x86_64 — that `make android-install` +would now refuse with `INSTALL_FAILED_NO_MATCHING_ABIS` — is **wrong, +and was measured wrong before it was written down.** Google's +`google_apis` x86_64 images carry arm64 translation: + +``` +ro.product.cpu.abilist = x86_64,arm64-v8a +``` + +So the arm64-only APK installs, the loader maps `lib/arm64/libwails.so` +and runs it (the tombstone says `Guest architecture: 'arm64'`). It then +dies **before any of our code**, with SIGILL rather than SIGSYS: + +``` +signal 4 (SIGILL), code -6 (SI_TKILL) +#00 pc 00000000015911d0 .../lib/arm64/libwails.so +``` + +Disassembling that offset names the reason exactly: + +``` +15911d0: d5380600 mrs x0, ID_AA64ISAR0_EL1 +``` + +That is Go's `internal/cpu` reading the arm64 CPU-feature ID register +at runtime init, which the translator does not implement. So it is not +"our Go program is unlucky": **no Go binary starts under this +translation layer**, and no amount of work on this app changes it. + +The three failures are worth holding side by side, because each looks +like the app's fault and none is: + +| build | on x86_64 Android | signal | +|---|---|---| +| x86_64 | modernc's raw `lstat` vs seccomp | SIGSYS, syscall 6 | +| arm64, translated | Go reads `ID_AA64ISAR0_EL1` | SIGILL | +| arm64, real device | — | unverified, still | + +**A physical arm64 device remains the only verification path.** ### What was fixed to get here @@ -155,12 +212,37 @@ the `indexbuild` tag. ### What is still not done -MPRIS is compiled in (`android` implies the `linux` build tag), the -shell is still a desktop shell, and — the largest one — open-*directory* -dialogs return an error on Android, because the Storage Access Framework -yields tree URIs rather than filesystem paths. This app's first run is -"choose your music folder" and its library model is filesystem paths, so -that is a design question rather than a port. +The shell is still a desktop shell, and the x86_64 half of the APK is +still dead weight. Everything in plan 016's section A is now built: +storage access, an in-app folder picker (Android's directory dialog +returns an error, since the Storage Access Framework yields tree URIs +rather than paths), MPRIS excluded, and a MediaSession with a transport +notification and audio focus. + +### Compiling the `android`-tagged Go by hand + +`make lint` and `make test` never see it: their three tag sets are all +linux/amd64, so the only thing that compiles `backend/mediacontrols/ +android.go` is `make android` — a full APK build for a Go type error. +The short way round: + +```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` is not optional.** Without it the oboe C++ sources in `oto` +compile against the host sysroot and fail on `android/log.h` and +`sys/system_properties.h`, which reads like a broken or missing NDK. +Restrict it to `./backend/...`: `./...` additionally builds +`build/android/gen`, a scaffold shim that only resolves inside the +wails task and fails with `undefined: main` on its own. + +A Go method added to a bound service also reaches the frontend unless +it says not to — `//wails:ignore` above the func, which `make bindings` +then honours. `Player.SetDuck` is driven by OS audio focus and carries +one. ## The scaffold's own tasks diff --git a/.planning/NOTES.md b/.planning/NOTES.md index 198fa6c..19ca079 100644 --- a/.planning/NOTES.md +++ b/.planning/NOTES.md @@ -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. diff --git a/.planning/plans/pending/016-android-feature-parity.md b/.planning/plans/pending/016-android-feature-parity.md index 46693c3..436a80e 100644 --- a/.planning/plans/pending/016-android-feature-parity.md +++ b/.planning/plans/pending/016-android-feature-parity.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 1757605..303da19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -343,7 +343,26 @@ rather than renaming them. came about. - `config` — TOML-based settings. Settings page uses HTMX + templ for server-rendered HTML fragments. - `playlist` / `smartplaylist` — Playlist CRUD and rule-based smart playlists. -- `mediacontrols` — MPRIS integration on Linux via D-Bus. +- `mediacontrols` — OS media controls behind one `Handler`: MPRIS over + D-Bus on desktop Linux, a MediaSession on Android, a no-op stub + elsewhere. The split is by build tag and `android` implies `linux`, + so the three files read `linux && !android`, `android` and `!linux`. + Its Android half needs no JNI beyond what Wails exports — a JSON + payload out through `application.Android.StartForegroundService`, a + command event back through `WailsBridge.emitEvent` — and the Java it + talks to is `build/android/.../WailsForegroundService.java`. That + contract (payload keys, state words, command names) is in + `androidpayload.go` **without** the build tag, because a tagged file + is compiled by nothing `make lint` or `make test` runs and is + untestable off a phone. + + `OnDuck` is the one callback MPRIS does not use: Android asks for + attenuation rather than a pause when something short needs the + output. `Player.SetDuck` keeps it as an offset on top of the user's + level rather than writing through to the volume, so it cannot + accumulate and nothing persists or emits a level the user did not + choose — and it only ever fires below API 26, where the framework + does not already duck the app itself. - `system` — OS-specific paths (XDG on Linux, `%LOCALAPPDATA%` on Windows). - `explore` — Catalog search and browse over `explore_index`. See below. Its **shelves** (`shelves.go`) are the page Explore shows before @@ -1774,8 +1793,9 @@ publish (`arch-package`, `homebrew-formula`, `index-artifact`, deciding whether a push was healthy. **`android-apk.yml` is the only one keyed on a tag and the only one -that can lose something irrecoverable.** It builds the signed fat APK -on every `v*` tag and publishes it to the *generic* registry, which is +that can lose something irrecoverable.** It builds the signed +`arm64-v8a` APK (the only ABI Android can run this app on — see +`app/build.gradle`) on every `v*` tag and publishes it to the *generic* registry, which is readable without credentials — the reason Obtainium can poll a plain URL. Android refuses to update an app whose signing certificate changed, and the only remedy is an uninstall that takes the user's diff --git a/docs/android-release.md b/docs/android-release.md index 8ff0410..3284d1c 100644 --- a/docs/android-release.md +++ b/docs/android-release.md @@ -1,7 +1,7 @@ # Releasing the Android APK -`.gitea/workflows/android-apk.yml` builds a signed fat APK -(`arm64-v8a` + `x86_64`) on every `v*` tag and publishes it to Gitea's +`.gitea/workflows/android-apk.yml` builds a signed `arm64-v8a` APK on +every `v*` tag and publishes it to Gitea's **generic** package registry, which is readable without credentials — which is what lets Obtainium poll a plain URL with no token. @@ -98,8 +98,12 @@ publish `1.100.0`**, and never move a tag that has already been built. ## What the workflow checks before publishing - the APK exists and is non-empty; -- it carries **both** ABIs (`native-code: 'arm64-v8a' 'x86_64'`), or it - is not the fat APK it claims to be; +- it carries **exactly one** ABI (`native-code: 'arm64-v8a'`). x86_64 + Android cannot run this app at all — `modernc.org/libc` issues a raw + `lstat` syscall that Android's seccomp policy forbids, on every + x86_64 device and not merely the emulator — so an x86_64 slice would + be ~31 MB that runs nowhere, and its reappearance means someone put + the ABI back in `app/build.gradle` without knowing that; - its `versionCode` is the one derived from the tag; - it is **not** signed with the debug key.