Compare commits

..
5 Commits
Author SHA1 Message Date
logan df2e9ea777 docs: record what the Android work established and disproved
Build & publish Arch package / arch-package (push) Successful in 2m33s
CI / check (push) Successful in 2m33s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Successful in 6m5s
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.
2026-08-16 22:26:39 -04:00
logan c99c8efa11 ci(android): tell a wrong password apart from a wrong keystore
The v1.5.0 run reported that the keystore did not open, and the
diagnostics could not say why. They now clear the two causes that look
identical to a wrong password.

**A password pasted with its shell quotes** is two characters longer
than the password and nothing in keytool's error says so. The step
retries with the surrounding quotes stripped and, if *that* opens the
keystore, says exactly that. It does not strip them and carry on: a
password may legitimately contain a quote, so this reports a diagnosis
rather than guessing at a fix.

**A password that is right for a different keystore** is the other one,
and it is the one currently in play -- the secret decodes to a valid
2280-byte PKCS12 and the password is the length the owner expects, which
leaves "is this the keystore I have locally?" as the open question. The
step prints the decoded file's sha256 so that is answerable by
comparing one line against sha256sum. Hashing a certificate store gives
nothing away.
2026-08-16 22:26:29 -04:00
logan 904786b941 fix(dev): the Android harness did not parse, and then chose any device
Two bugs, and the first had made every make android-* target dead since
the commit that introduced it.

**The script did not parse at all.** A case pattern read
`*signatures do not match*)`, and `do` is a reserved word: bash rejects
the *whole file*, so android-emulator, android-install, android-smoke
and android-logs all died with "line 190: syntax error near unexpected
token `do'" -- a message that points at a line nobody had reason to
suspect, in a file that had been working. Quoting the inner words fixes
it. A shell script only ever run by hand can carry a syntax error
indefinitely; nothing in the pre-commit hooks runs bash -n.

**A bare adb addresses whatever is attached.** With a second emulator
present -- another project's, or this one's own corpse left `offline` by
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". Which
is the harness's own house rule broken: a failure that names the wrong
cause is worse than one that names none.

pick_device resolves ANDROID_SERIAL from ro.boot.qemu.avd_name before
any device command. The AVD name is the identity because serials are
assigned in boot order and change between runs; a caller's own
ANDROID_SERIAL wins, and a single device that is not ours is taken as
the target, since that is a phone and a phone is what this tier
actually wants. Verified with both emulators running.
2026-08-16 22:26:21 -04:00
logan b6651310ea build(android): drop the x86_64 ABI, which no Android can run
The fat APK's second half was 31 MB that cannot execute on any Android
device. modernc.org/libc's Xlstat64 issues a raw lstat syscall on
linux/amd64, and Android's seccomp policy forbids it because bionic
never issues it, so the process takes SIGSYS the first time anything
touches the database -- which for this app is startup. That is every
x86_64 Android, x86 Chromebooks included, not merely the emulator.
arm64 is structurally unaffected: the architecture has no lstat syscall
at all, so modernc routes through fstatat.

27,059,130 bytes to 15,898,465, and one lib/ entry.

Three places had to agree, and the third is what would have made this a
silent no-op: abiFilters (what Gradle packages), android:package rather
than package:fat (what Go *compiles* -- otherwise the library is still
built and then discarded), and the native-code assertion in CI. That
assertion is anchored, `native-code: 'arm64-v8a'$`, because without the
anchor it also matches the fat APK's line and would pass on exactly the
thing it exists to catch. Checked against a real artifact.

Adding the ABI back, if modernc ever fixes Xlstat64, is those same
three edits.
2026-08-16 22:26:11 -04:00
logan da38b865fc feat(android): playback that survives the screen locking
An app that plays audio becomes a music player at the point where the
screen can lock, a call can interrupt, and the headphones can come out.
None of that existed: the foreground service was typed for media but
had no MediaSession, no transport notification and no audio focus, so
oto would happily keep writing to a stream nobody could hear.

The apparent blocker is that Wails' androidBridge* helpers are
unexported, so Go cannot call arbitrary Java. It does not need to.
StartForegroundService(json) *is* exported, and build/android/ is our
tree, so widening the JSON WailsBridge already accepts is a local edit;
coming back, WailsBridge.emitEvent lands on the application event bus,
which Go subscribes to with app.Event.On. One document out, one command
event back, and no new JNI. No new Gradle dependency either: minSdk is
21, which is exactly when android.media.session.MediaSession and
Notification.MediaStyle arrived, so androidx.media buys two
Build.VERSION branches' worth of nothing.

Four things in it are load-bearing.

**A duck is not a volume change.** Player.SetDuck holds the attenuation
as an 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. Writing 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; 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**, the rule emitStatus already
states one package over: every push crosses JNI and re-delivers an
Intent, and the player pushes state on several paths that can agree.

**After the first start, an update is startService.** From Android 12 a
background app may not *start* a foreground service but may keep
feeding one it already has, which is every track change with the screen
off. Relatedly, every path through onStartCommand calls startForeground
-- one that returns without it is killed.

The contract with Java lives in androidpayload.go *without* the android
build tag, and is tested. Everything left in android.go is untested by
construction: make lint and make test are three tag sets on
linux/amd64, so the only thing that compiles it is the cross-compiler
in make android, and the only thing that can run it is a phone.

None of the behaviour above has been observed on a device. The APK
builds and both halves compile; that is the whole of what is verified.
2026-08-16 22:26:03 -04:00
20 changed files with 1640 additions and 108 deletions
+36 -5
View File
@@ -1,7 +1,7 @@
name: Build & publish the Android APK
# The fifth workflow, and the second that publishes. It builds a signed
# fat APK (arm64-v8a + x86_64) on every version tag and puts it in
# arm64-v8a APK on every version tag and puts it in
# Gitea's *generic* package registry, which — unlike the repository — is
# readable without credentials. That is what lets an Obtainium client
# poll a plain URL with no token and no public mirror of the source.
@@ -225,7 +225,7 @@ jobs:
# `$GITHUB_ENV` — where the `env:` dump is only masked for values
# that are *verbatim* a secret, so a trimmed one could print in
# clear — or repeating the trimming logic in both.
- name: Build the signed fat APK
- name: Build the signed APK
working-directory: /src
env:
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
@@ -277,6 +277,18 @@ jobs:
size=$(stat -c %s "$keystore")
magic=$(od -An -N4 -tx1 "$keystore" | tr -s ' ' | sed 's/^ //')
echo "keystore: $size bytes, first four bytes: $magic"
# The fingerprint of the decoded file, so "is the secret the
# keystore I have locally?" is answerable without guessing.
# A hash of a *public* certificate store gives nothing away,
# and the alternative is comparing byte counts by eye.
#
# sha256sum ~/path/to/yellowjacket-release.jks
#
# A password that is right for one keystore and wrong for
# another is indistinguishable from a wrong password, and this
# is the line that distinguishes them.
echo " sha256: $(sha256sum "$keystore" | cut -d' ' -f1)"
case "$magic" in
"30 82"*) echo " header: PKCS12 (keytool's default since JDK 9)" ;;
"fe ed fe ed") echo " header: legacy JKS" ;;
@@ -291,6 +303,20 @@ jobs:
echo " password length after trimming: ${#pass}" >&2
sed 's/^/ keytool: /' /tmp/ks.err | head -5 >&2
echo >&2
# A password pasted *with its shell quotes* is the one
# remaining cause that looks identical to a wrong password:
# the secret is two characters longer than the password and
# nothing in the error says so. Naming it is safe --
# stripping the quotes and carrying on would not be, since a
# password may legitimately contain them.
unquoted=$(printf '%s' "$pass" | sed "s/^['\"]//;s/['\"]$//")
if [ "$unquoted" != "$pass" ] &&
keytool -list -keystore "$keystore" -storepass "$unquoted" >/dev/null 2>&1; then
echo " ** it opens with the surrounding quotes removed. **" >&2
echo " Re-paste ANDROID_KEYSTORE_PASSWORD without them." >&2
echo >&2
fi
echo "Check it locally with the same two values:" >&2
echo " printf %s \"\$SECRET_B64\" | base64 -d > /tmp/k.jks" >&2
echo " keytool -list -keystore /tmp/k.jks -storepass '<password>'" >&2
@@ -333,9 +359,14 @@ jobs:
ls -la "$apk"
"$bt/aapt2" dump badging "$apk" | sed -n '1p;/application-label:/p;/native-code/p'
# Both ABIs, or the artifact is not the fat APK it claims to be.
"$bt/aapt2" dump badging "$apk" | grep -q "native-code: 'arm64-v8a' 'x86_64'" || {
echo "the APK does not carry both ABIs" >&2; exit 1; }
# arm64 and *only* arm64. x86_64 Android cannot run this app
# (modernc's raw lstat against Android's seccomp filter, which
# is every x86_64 device and not merely the emulator), so an
# x86_64 slice would be ~31 MB that runs nowhere -- and its
# reappearance would mean someone had put the ABI back in
# app/build.gradle without knowing that.
"$bt/aapt2" dump badging "$apk" | grep -q "native-code: 'arm64-v8a'$" || {
echo "the APK's ABI set is not exactly arm64-v8a" >&2; exit 1; }
# The identity the pipeline exists to keep stable.
"$bt/aapt2" dump badging "$apk" | grep -q "versionCode='${{ steps.version.outputs.code }}'" || {
@@ -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
+169
View File
@@ -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.
+23 -3
View File
@@ -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
+7 -2
View File
@@ -52,8 +52,13 @@ ANDROID_SDK ?= $(HOME)/Android/Sdk
ANDROID_NDK ?= /opt/android-ndk
ANDROID_ENV := ANDROID_HOME=$(ANDROID_SDK) ANDROID_SDK_ROOT=$(ANDROID_SDK) ANDROID_NDK_HOME=$(ANDROID_NDK)
android: build-frontend ## Build the fat APK (arm64 + x86_64) into bin/
@$(ANDROID_ENV) PATH="$(TOOLBIN):$$PATH" go tool wails3 task android:package:fat
# `package`, not `package:fat`: x86_64 Android cannot run this app at
# all (modernc's raw lstat vs Android's seccomp -- see
# android-tier.md), so the second ABI was ~31 MB that could not run
# anywhere. app/build.gradle's abiFilters says the same thing to
# Gradle; both have to agree or the .so is built and then dropped.
android: build-frontend ## Build the arm64 APK into bin/
@$(ANDROID_ENV) PATH="$(TOOLBIN):$$PATH" go tool wails3 task android:package
android-setup: ## Install the SDK pieces and create the AVD (once, ~3.5GB)
@$(ANDROID_ENV) ./scripts/android-emulator.sh setup
+10 -5
View File
@@ -484,20 +484,24 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
// Register playback finished handler to drive queue auto-advance.
yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished)
// Initialize OS media controls (MPRIS on Linux, no-op elsewhere).
// Initialize OS media controls (MPRIS on desktop Linux, a
// MediaSession on Android, no-op elsewhere). The callbacks are the
// same on every platform; only what delivers them differs.
yj.mediaControls = mediacontrols.NewHandler(yj.logger)
if err := yj.mediaControls.Init(mediacontrols.Callbacks{
OnPlay: yj.queue.Play,
OnPause: func() {
if err := yj.player.Pause(); err != nil {
yj.logger.Warn("MPRIS Pause failed", "err", err)
yj.logger.Warn("Media controls Pause failed", "err", err)
}
},
OnPlayPause: func() {
if yj.player.IsPlaying() {
if err := yj.player.Pause(); err != nil {
yj.logger.Warn("MPRIS PlayPause(pause) failed", "err", err)
yj.logger.Warn(
"Media controls PlayPause(pause) failed", "err", err,
)
}
} else {
yj.queue.Play()
@@ -505,14 +509,14 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
},
OnStop: func() {
if err := yj.player.Pause(); err != nil {
yj.logger.Warn("MPRIS Stop failed", "err", err)
yj.logger.Warn("Media controls Stop failed", "err", err)
}
},
OnNext: yj.queue.Next,
OnPrevious: yj.queue.Previous,
OnSeek: func(positionSec int) {
if err := yj.player.Seek(positionSec); err != nil {
yj.logger.Warn("MPRIS Seek failed", "err", err)
yj.logger.Warn("Media controls Seek failed", "err", err)
}
},
OnVolume: func(vol float64) {
@@ -522,6 +526,7 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
),
)
},
OnDuck: yj.player.SetDuck,
}); err != nil {
yj.logger.Error(
"Failed to initialize media controls",
+215
View File
@@ -0,0 +1,215 @@
//go:build android
// Android's answer to MPRIS is a MediaSession, and reaching it needs no
// new JNI: Wails exports application.Android.StartForegroundService(json)
// going out, and Java's WailsBridge.emitEvent lands on the application
// event bus coming back. So this handler is one JSON payload pushed to
// the foreground service and one command event read from it. The Java
// half is
// build/android/app/src/main/java/com/wails/app/WailsForegroundService.java
// and the payload keys below are its contract.
package mediacontrols
import (
"errors"
"log/slog"
"sync"
"github.com/wailsapp/wails/v3/pkg/application"
)
// commandEvent is the event name the Java side emits transport
// commands on. It is a plain string on both sides; changing it means
// changing WailsForegroundService too.
const commandEvent = "yj:media:command"
var errNoApplication = errors.New(
"no running application to attach media controls to",
)
// androidHandler drives the media notification, the lock-screen
// transport and audio focus through the foreground service.
type androidHandler struct {
logger *slog.Logger
mu sync.Mutex
callbacks Callbacks
meta Metadata
state PlaybackState
positionSec int
// running tracks whether the foreground service has been started.
// Android 12+ forbids starting one from the background, so it is
// started when playback starts -- a user action, in a visible app
// -- and stopped only when playback stops, which is what keeps
// queue auto-advance working with the screen off.
running bool
// lastPayload is the last JSON sent. 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.
lastPayload string
unsubscribe func()
}
// NewHandler returns the Android media-session handler.
func NewHandler(logger *slog.Logger) Handler {
return &androidHandler{logger: logger, state: StateStopped}
}
// Init subscribes to the transport commands the Java side emits.
func (a *androidHandler) Init(callbacks Callbacks) error {
app := application.Get()
if app == nil {
return errNoApplication
}
a.mu.Lock()
a.callbacks = callbacks
a.mu.Unlock()
a.unsubscribe = app.Event.On(commandEvent, a.onCommand)
return nil
}
// onCommand dispatches one transport command from the notification,
// the lock screen, a headset button or an audio-focus change.
//
// Every callback runs on its own goroutine, for the reason the MPRIS
// handler does the same: they take the player and queue mutexes, and
// this runs on the event processor's dispatch goroutine.
func (a *androidHandler) onCommand(event *application.CustomEvent) {
data, ok := event.Data.(map[string]any)
if !ok {
return
}
command := parseMediaCommand(data)
a.mu.Lock()
cb := a.callbacks
a.mu.Unlock()
switch command.name {
case cmdPlay:
run(cb.OnPlay)
case cmdPause:
run(cb.OnPause)
case cmdPlayPause:
run(cb.OnPlayPause)
case cmdStop:
run(cb.OnStop)
case cmdNext:
run(cb.OnNext)
case cmdPrevious:
run(cb.OnPrevious)
case cmdSeek:
if cb.OnSeek != nil {
go cb.OnSeek(command.positionSec)
}
case cmdDuck:
if cb.OnDuck != nil {
go cb.OnDuck(command.duck)
}
default:
a.logger.Warn("Unknown media command", "command", command.name)
}
}
// run invokes a callback on its own goroutine, tolerating a nil one.
func run(fn func()) {
if fn != nil {
go fn()
}
}
// UpdateMetadata pushes new track details to the notification.
func (a *androidHandler) UpdateMetadata(meta Metadata) {
a.mu.Lock()
defer a.mu.Unlock()
a.meta = meta
a.push()
}
// UpdatePlaybackState pushes the state and a fresh position anchor;
// the MediaSession interpolates from there while playing.
func (a *androidHandler) UpdatePlaybackState(
state PlaybackState,
positionSec int,
) {
a.mu.Lock()
defer a.mu.Unlock()
a.state = state
a.positionSec = positionSec
a.push()
}
// NotifySeek re-anchors the position. Unlike MPRIS, a MediaSession has
// no separate seeked signal -- a new state with a new position is the
// whole mechanism.
func (a *androidHandler) NotifySeek(positionSec int) {
a.mu.Lock()
defer a.mu.Unlock()
a.positionSec = positionSec
a.push()
}
// UpdateVolume is deliberately a no-op. Android's volume keys act on
// the media stream, which the OS owns; an app that also moved its own
// volume in response would move it twice.
func (a *androidHandler) UpdateVolume(_ float64) {}
// Close stops the service and drops the command subscription.
func (a *androidHandler) Close() {
a.mu.Lock()
defer a.mu.Unlock()
if a.unsubscribe != nil {
a.unsubscribe()
a.unsubscribe = nil
}
if a.running {
application.Android.StopForegroundService()
a.running = false
}
}
// push sends the current state to the Java side, if it has changed.
// The caller holds a.mu.
func (a *androidHandler) push() {
if a.state == StateStopped {
// Nothing is playing, so nothing justifies an ongoing
// notification or the process staying alive.
if a.running {
application.Android.StopForegroundService()
a.running = false
a.lastPayload = ""
}
return
}
payload, err := mediaPayload(a.meta, a.state, a.positionSec)
if err != nil {
a.logger.Error("Failed to encode media payload", "err", err)
return
}
if payload == a.lastPayload {
return
}
a.lastPayload = payload
a.running = true
application.Android.StartForegroundService(payload)
}
+85
View File
@@ -0,0 +1,85 @@
// The contract between the Android handler and the Java
// WailsForegroundService is two JSON documents -- one pushed out with
// the track and the state, one read back with a transport command --
// and neither side can check the other.
//
// It lives here, *without* the android build tag, so that `go test` on
// any platform exercises it. android.go itself can only be compiled by
// a cross-compiler and only be run by a phone, so anything left in it
// is untested by construction; this is the half worth not leaving
// there.
package mediacontrols
import "encoding/json"
// Media command names, as the Java side spells them.
const (
cmdPlay = "play"
cmdPause = "pause"
cmdPlayPause = "playpause"
cmdStop = "stop"
cmdNext = "next"
cmdPrevious = "previous"
cmdSeek = "seek"
cmdDuck = "duck"
)
// stateNames are what the payload's "state" key carries. Words rather
// than the PlaybackState integers, because the Java side reads them as
// JSON and a renumbered constant would silently mean something else
// there.
var stateNames = map[PlaybackState]string{
StateStopped: "stopped",
StatePlaying: "playing",
StatePaused: "paused",
}
// mediaCommand is one transport command from the notification, the
// lock screen, a headset button or an audio-focus change.
type mediaCommand struct {
name string
positionSec int
duck bool
}
// mediaPayload encodes the state the notification and MediaSession
// render.
func mediaPayload(
meta Metadata,
state PlaybackState,
positionSec int,
) (string, error) {
payload, err := json.Marshal(map[string]any{
"title": meta.Title,
"artist": meta.Artist,
"album": meta.Album,
"artPath": meta.ArtFilePath,
"durationSec": meta.DurationSec,
"positionSec": positionSec,
"state": stateNames[state],
})
if err != nil {
return "", err
}
return string(payload), nil
}
// parseMediaCommand reads one command out of the event payload.
//
// The numbers arrive as float64 because they came through
// encoding/json as an untyped document -- asserting int here is the
// way a seek silently becomes a seek to zero.
func parseMediaCommand(data map[string]any) mediaCommand {
cmd := mediaCommand{}
cmd.name, _ = data["command"].(string)
if position, ok := data["positionSec"].(float64); ok {
cmd.positionSec = int(position)
}
cmd.duck, _ = data["on"].(bool)
return cmd
}
@@ -0,0 +1,165 @@
package mediacontrols
import (
"encoding/json"
"testing"
)
// TestMediaPayloadKeys pins the document the Java side parses. The
// keys are the contract: a rename here is silently a track with no
// title on the lock screen, because WailsForegroundService reads them
// with optString and a missing key is simply "".
func TestMediaPayloadKeys(t *testing.T) {
t.Parallel()
payload, err := mediaPayload(Metadata{
Title: "Tideline",
Artist: "Sea Change",
Album: "Ebb",
ArtFilePath: "/covers/ebb_lg.jpg",
DurationSec: 245,
}, StatePlaying, 30)
if err != nil {
t.Fatalf("mediaPayload: %v", err)
}
var got map[string]any
if err := json.Unmarshal([]byte(payload), &got); err != nil {
t.Fatalf("payload is not JSON: %v", err)
}
want := map[string]any{
"title": "Tideline",
"artist": "Sea Change",
"album": "Ebb",
"artPath": "/covers/ebb_lg.jpg",
"durationSec": float64(245),
"positionSec": float64(30),
"state": "playing",
}
if len(got) != len(want) {
t.Errorf("payload has %d keys, want %d: %s", len(got), len(want), payload)
}
for key, expected := range want {
if got[key] != expected {
t.Errorf("payload[%q] = %v, want %v", key, got[key], expected)
}
}
}
// TestMediaPayloadStateNames covers the one value the Java side
// compares against a literal.
func TestMediaPayloadStateNames(t *testing.T) {
t.Parallel()
tests := []struct {
state PlaybackState
want string
}{
{StatePlaying, "playing"},
{StatePaused, "paused"},
{StateStopped, "stopped"},
}
for _, tt := range tests {
payload, err := mediaPayload(Metadata{}, tt.state, 0)
if err != nil {
t.Fatalf("mediaPayload: %v", err)
}
var got struct {
State string `json:"state"`
}
if err := json.Unmarshal([]byte(payload), &got); err != nil {
t.Fatalf("payload is not JSON: %v", err)
}
if got.State != tt.want {
t.Errorf("state %d encoded as %q, want %q", tt.state, got.State, tt.want)
}
}
}
// TestParseMediaCommand covers the direction that arrives untyped.
// The seek case is the one with teeth: the position crosses as a JSON
// number, so it is a float64 in the map and an int assertion would
// make every seek a seek to zero.
func TestParseMediaCommand(t *testing.T) {
t.Parallel()
tests := []struct {
name string
data map[string]any
want mediaCommand
}{
{
name: "play",
data: map[string]any{"command": "play"},
want: mediaCommand{name: cmdPlay},
},
{
name: "seek carries a position",
data: map[string]any{"command": "seek", "positionSec": float64(93)},
want: mediaCommand{name: cmdSeek, positionSec: 93},
},
{
name: "duck carries a flag",
data: map[string]any{"command": "duck", "on": true},
want: mediaCommand{name: cmdDuck, duck: true},
},
{
name: "unduck",
data: map[string]any{"command": "duck", "on": false},
want: mediaCommand{name: cmdDuck},
},
{
name: "a command with nothing in it is not a panic",
data: map[string]any{},
want: mediaCommand{},
},
{
name: "wrongly typed fields fall back to zero",
data: map[string]any{"command": "seek", "positionSec": "93"},
want: mediaCommand{name: cmdSeek},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := parseMediaCommand(tt.data); got != tt.want {
t.Errorf("parseMediaCommand(%v) = %+v, want %+v", tt.data, got, tt.want)
}
})
}
}
// TestMediaCommandNamesAreWhatJavaSends is a spelling check against
// the Java side, which builds these strings by hand. It is a list, not
// a mechanism: nothing can reach across into the .java file, so the
// point is that changing one of these constants fails a test that
// names the file to change with it.
//
// See build/android/app/src/main/java/com/wails/app/WailsForegroundService.java.
func TestMediaCommandNamesAreWhatJavaSends(t *testing.T) {
t.Parallel()
want := []string{
"play", "pause", "playpause", "stop",
"next", "previous", "seek", "duck",
}
got := []string{
cmdPlay, cmdPause, cmdPlayPause, cmdStop,
cmdNext, cmdPrevious, cmdSeek, cmdDuck,
}
for i, name := range want {
if got[i] != name {
t.Errorf("command %d = %q, want %q", i, got[i], name)
}
}
}
+7
View File
@@ -34,6 +34,13 @@ type Callbacks struct {
OnPrevious func()
OnSeek func(positionSec int)
OnVolume func(volume float64) // 0.0–1.0 linear scale.
// OnDuck asks for playback to be attenuated (true) or restored
// (false) without changing the user's volume. Android alone sends
// it, and only below API 26 -- from Oreo the audio framework ducks
// the app itself and reports no such focus change, so doing both
// would attenuate twice.
OnDuck func(ducked bool)
}
// Handler manages the OS media control integration.
+5 -6
View File
@@ -1,10 +1,9 @@
//go:build !linux || android
//go:build !linux
// Android is covered here rather than by mpris_linux.go: it satisfies
// the `linux` tag but has no D-Bus session bus. Its real equivalent is
// a MediaSession, which is Java-side work and not yet built -- so for
// now the app simply has no lock-screen transport there, which is a
// missing feature rather than a broken one.
// Windows and macOS have no media-control integration yet. `!linux`
// covers Android too without naming it, since `android` implies the
// `linux` tag -- android.go claims it, mpris_linux.go excludes it, and
// this file is left with the platforms neither wants.
package mediacontrols
+39 -2
View File
@@ -56,6 +56,13 @@ type Player struct {
trackChangeID uint64
mediaControls mediacontrols.Handler
// duckAmount is the attenuation currently applied on top of the
// user's volume, in the same base-2 exponent effects.Volume uses.
// It is deliberately not persisted and emits no VolumeChanged: a
// duck is something the OS did for the length of a notification,
// not something the user chose.
duckAmount float64
// trackLengthMs holds the authoritative track duration in
// milliseconds, sourced from the database (which uses the
// custom header parser). The go-mp3 decoder's Len() can be
@@ -793,12 +800,40 @@ func (p *Player) setVolumeLocked(desiredVolume UserVolume) {
speaker.Lock()
volume := clampVolume(desiredVolume)
p.volume.Volume = float64(volume.ToVolume())
p.volume.Volume = float64(volume.ToVolume()) - p.duckAmount
p.volume.Silent = volume == MinUserVol
speaker.Unlock()
}
// SetDuck attenuates playback (or restores it) without changing the
// user's volume, for an OS that has asked us to get out of the way of
// something short -- a navigation prompt, a notification tone.
//
// It re-applies the *user's* level through setVolumeLocked rather than
// nudging the effect directly, so the offset cannot accumulate across
// repeated ducks, and it neither emits nor persists: the level the user
// set has not changed and the UI must not claim it has.
//
//wails:ignore // driven by OS audio focus, not by the frontend.
func (p *Player) SetDuck(ducked bool) {
p.mu.Lock()
defer p.mu.Unlock()
amount := 0.0
if ducked {
amount = duckAttenuation
}
if p.volume == nil || amount == p.duckAmount {
return
}
current := p.getUserVolume()
p.duckAmount = amount
p.setVolumeLocked(current)
}
// ChangeVolume adjusts the volume by a relative amount.
func (p *Player) ChangeVolume(deltaVolume int) error {
p.mu.Lock()
@@ -812,7 +847,9 @@ func (p *Player) ChangeVolume(deltaVolume int) error {
}
func (p *Player) getUserVolume() UserVolume {
return Volume(p.volume.Volume).ToUserVolume()
// Undo any duck, so every caller -- the event, the persisted
// state, a relative change -- sees the level the user chose.
return Volume(p.volume.Volume + p.duckAmount).ToUserVolume()
}
// Muted reports whether playback is currently silenced.
+6
View File
@@ -19,6 +19,12 @@ const (
MaxVol Volume = 0
)
// duckAttenuation is how far playback drops when the OS asks us to
// duck, on the same base-2 exponent scale: two steps is a quarter of
// the amplitude (-12 dB), which is audible under a spoken notification
// without sounding like a pause.
const duckAttenuation = 2.0
// ToVolume converts user volume to internal player volume.
func (oldVol UserVolume) ToVolume() Volume {
var newVol Volume
+60
View File
@@ -1,9 +1,12 @@
package player
import (
"log/slog"
"math"
"testing"
"github.com/gopxl/beep/v2/effects"
"yellowjacket/backend/mediacontrols"
)
@@ -202,3 +205,60 @@ func TestStateToMediaControls(t *testing.T) {
})
}
}
// TestSetDuck covers the property the duck rests on: the attenuation
// is applied to the output and is invisible to everything that asks
// what the volume is -- the event, the persisted state, a relative
// change. Getting that wrong would let one notification tone
// permanently rewrite the user's volume.
func TestSetDuck(t *testing.T) {
t.Parallel()
p := NewPlayer(slog.Default(), nil)
p.volume = &effects.Volume{Base: 2}
p.setVolumeLocked(80)
unducked := p.volume.Volume
p.SetDuck(true)
if p.volume.Volume >= unducked {
t.Errorf(
"ducked output volume = %v, want less than %v",
p.volume.Volume, unducked,
)
}
if got := p.getUserVolume(); got != 80 {
t.Errorf("user volume while ducked = %d, want 80", got)
}
// A second duck must not stack: the offset is re-applied to the
// user's level, never subtracted again from the current output.
ducked := p.volume.Volume
p.SetDuck(true)
if p.volume.Volume != ducked {
t.Errorf(
"duck applied twice = %v, want %v", p.volume.Volume, ducked,
)
}
// Changing the volume while ducked keeps the attenuation.
p.setVolumeLocked(60)
if got := p.getUserVolume(); got != 60 {
t.Errorf("user volume set while ducked = %d, want 60", got)
}
if want := float64(UserVolume(60).ToVolume()) - duckAttenuation; p.volume.Volume != want {
t.Errorf("output while ducked = %v, want %v", p.volume.Volume, want)
}
p.SetDuck(false)
if want := float64(UserVolume(60).ToVolume()); p.volume.Volume != want {
t.Errorf("output after unduck = %v, want %v", p.volume.Volume, want)
}
}
+14 -3
View File
@@ -40,9 +40,21 @@ android {
versionCode Integer.parseInt(System.getenv("YJ_VERSION_CODE") ?: "1")
versionName System.getenv("YJ_VERSION") ?: "0.0.0"
// Configure supported ABIs
// **arm64 only, and x86_64 is not a gap.** `modernc.org/libc`'s
// Xlstat64 issues a raw lstat syscall on linux/amd64, which
// Android's seccomp policy forbids (bionic never issues it), so
// the process takes SIGSYS the first time anything touches the
// database -- which for this app is startup. That is every
// x86_64 Android, emulators and x86 Chromebooks alike, not just
// some. arm64 is structurally unaffected: the architecture has
// no lstat syscall at all, so modernc routes through fstatat.
//
// So the second ABI was ~31 MB of an artifact that could not run
// anywhere. If modernc fixes it, adding 'x86_64' back here and
// to the native-code assertion in android-apk.yml is the whole
// change.
ndk {
abiFilters 'arm64-v8a', 'x86_64'
abiFilters 'arm64-v8a'
}
}
@@ -93,7 +105,6 @@ android {
packagingOptions {
// Don't strip Go symbols in debug builds
doNotStrip '*/arm64-v8a/libwails.so'
doNotStrip '*/x86_64/libwails.so'
}
}
@@ -81,6 +81,9 @@ public class WailsBridge {
System.loadLibrary("wails");
}
/** The live bridge, for in-process components that are not given one. */
private static volatile WailsBridge instance;
private final Activity activity;
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private WebView webView;
@@ -122,6 +125,7 @@ public class WailsBridge {
public WailsBridge(Activity activity) {
this.activity = activity;
instance = this;
}
/**
@@ -210,6 +214,19 @@ public class WailsBridge {
if (initialized) nativeEmitEvent(name, json);
}
/**
* Emit an event from a component that holds no bridge reference —
* {@link WailsForegroundService}, which Android constructs itself. It is a
* static hop rather than a binder because the service runs in this same
* process; before the bridge exists (or after it is gone) the event is
* dropped, which is the same thing {@link #emitEvent} does when the native
* library has not been initialized.
*/
public static void emitFromService(String name, String json) {
WailsBridge b = instance;
if (b != null) b.emitEvent(name, json);
}
/**
* Serve an asset from the Go asset server
*/
@@ -1193,7 +1210,16 @@ public class WailsBridge {
i.setAction(WailsForegroundService.ACTION_START);
i.putExtra("title", title);
i.putExtra("text", text);
ContextCompat.startForegroundService(activity, i);
// The whole document, for the media service: seven extras
// would be seven chances for the two sides to disagree about
// a key, and the service already has to parse JSON for the
// fields the scaffold's title/text pair cannot carry.
i.putExtra("payload", json);
if (WailsForegroundService.running) {
activity.startService(i);
} else {
ContextCompat.startForegroundService(activity, i);
}
emitEvent("android:foregroundService", "{\"running\":true}");
} catch (Exception e) {
Log.e(TAG, "startForegroundService failed", e);
@@ -4,71 +4,545 @@ import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.ServiceInfo;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.media.AudioAttributes;
import android.media.AudioFocusRequest;
import android.media.AudioManager;
import android.media.MediaMetadata;
import android.media.session.MediaSession;
import android.media.session.PlaybackState;
import android.os.Build;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.util.Log;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
import org.json.JSONObject;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* A minimal started foreground service. It does no work of its own — its purpose
* is to keep the app's process alive (with the required ongoing notification) so
* the developer's Go goroutines keep running while the app is backgrounded,
* which Android would otherwise be free to kill. Start it from
* {@link WailsBridge#startForegroundService(String)} and stop it with
* {@link WailsBridge#stopForegroundService()}.
* The foreground service that keeps playback alive with the screen off, and
* the app's whole media-control surface: a {@link MediaSession} for the lock
* screen and headset buttons, a transport notification, and audio focus.
*
* <p>The scaffold shipped this as a generic "keep the process alive" service
* typed {@code dataSync}. YellowJacket's reason for staying alive in the
* background is that a song is playing, so it is {@code mediaPlayback} — the
* manifest and {@code startForeground} must agree on that or the call throws.
*
* <p>It is driven entirely from Go. {@code backend/mediacontrols/android.go}
* pushes a JSON payload through
* {@link WailsBridge#startForegroundService(String)}, and every command the
* user gives here — a notification button, the lock screen, a headset, or the
* OS taking audio focus away — goes back the other way as a
* {@code yj:media:command} event. Nothing about playback is decided here: this
* class renders state and reports intent.
*/
public class WailsForegroundService extends android.app.Service {
public static final String ACTION_START = "com.wails.app.FGS_START";
private static final String CHANNEL_ID = "wails_foreground";
// Transport actions, delivered to ourselves by the notification's
// PendingIntents. getService rather than a broadcast: a receiver would
// have to be exported or registered, and this service is already the
// thing that has to be running for any of them to be meaningful.
private static final String ACTION_PLAY = "com.wails.app.MEDIA_PLAY";
private static final String ACTION_PAUSE = "com.wails.app.MEDIA_PAUSE";
private static final String ACTION_NEXT = "com.wails.app.MEDIA_NEXT";
private static final String ACTION_PREVIOUS = "com.wails.app.MEDIA_PREVIOUS";
private static final String TAG = "WailsMedia";
private static final String CHANNEL_ID = "yellowjacket_playback";
private static final int NOTIFICATION_ID = 0x57A1; // "WAI"
private static final String COMMAND_EVENT = "yj:media:command";
/** Cover art is decoded down to this, which is larger than any lock screen. */
private static final int ART_MAX_PX = 512;
/**
* Whether an instance is alive. {@link WailsBridge} reads it to decide
* between startForegroundService and startService: from Android 12 an app
* in the background may not <em>start</em> a foreground service, but it
* may go on delivering intents to one it already has — and every update
* after the first (a track change with the screen off, most of them) is
* exactly that case.
*/
static volatile boolean running = false;
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private final ExecutorService artExecutor = Executors.newSingleThreadExecutor();
private MediaSession session;
private AudioManager audioManager;
private AudioFocusRequest focusRequest; // API 26+ only.
private AudioManager.OnAudioFocusChangeListener focusListener;
private String title = "";
private String artist = "";
private String album = "";
private String artPath = "";
private long durationMs = 0;
private long positionMs = 0;
private boolean playing = false;
private Bitmap art;
private boolean hasFocus = false;
/**
* Whether *we* paused because focus went away. Only then does regaining it
* resume: a user who paused during a phone call did not ask us to start
* again when it ended.
*/
private boolean pausedByFocusLoss = false;
private boolean noisyRegistered = false;
/** Headphones pulled out. Anything else and the room hears the album. */
private final BroadcastReceiver noisyReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (AudioManager.ACTION_AUDIO_BECOMING_NOISY.equals(intent.getAction())) {
emitCommand("pause");
}
}
};
@Override
public void onCreate() {
super.onCreate();
running = true;
audioManager = (AudioManager) getSystemService(AUDIO_SERVICE);
createChannel();
createSession();
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
String title = "Wails";
String text = "Running in the background";
if (intent != null) {
if (intent.getStringExtra("title") != null) title = intent.getStringExtra("title");
if (intent.getStringExtra("text") != null) text = intent.getStringExtra("text");
String action = intent == null ? null : intent.getAction();
if (ACTION_PLAY.equals(action)) {
emitCommand("play");
} else if (ACTION_PAUSE.equals(action)) {
emitCommand("pause");
} else if (ACTION_NEXT.equals(action)) {
emitCommand("next");
} else if (ACTION_PREVIOUS.equals(action)) {
emitCommand("previous");
} else if (intent != null) {
applyPayload(intent);
}
NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel ch = new NotificationChannel(
CHANNEL_ID, "Background work", NotificationManager.IMPORTANCE_LOW);
nm.createNotificationChannel(ch);
// Unconditionally, on every path: a service started with
// startForegroundService that returns from onStartCommand without
// calling startForeground is killed with a
// ForegroundServiceDidNotStartInTimeException.
goForeground();
return START_STICKY;
}
/**
* Read the state Go pushed. "payload" is the whole JSON document; the
* title/text extras are the scaffold's original contract and are kept as a
* fallback so a non-media caller still gets a sensible notification.
*/
private void applyPayload(Intent intent) {
String payload = intent.getStringExtra("payload");
if (payload == null || payload.isEmpty()) {
if (intent.getStringExtra("title") != null) {
title = intent.getStringExtra("title");
}
if (intent.getStringExtra("text") != null) {
artist = intent.getStringExtra("text");
}
return;
}
PendingIntent contentIntent = null;
Intent launch = getPackageManager().getLaunchIntentForPackage(getPackageName());
if (launch != null) {
int piFlags = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
? PendingIntent.FLAG_IMMUTABLE : 0;
contentIntent = PendingIntent.getActivity(this, 0, launch, piFlags);
try {
JSONObject o = new JSONObject(payload);
title = o.optString("title", "");
artist = o.optString("artist", "");
album = o.optString("album", "");
durationMs = o.optLong("durationSec", 0) * 1000L;
positionMs = o.optLong("positionSec", 0) * 1000L;
playing = "playing".equals(o.optString("state", "paused"));
String path = o.optString("artPath", "");
if (!path.equals(artPath)) {
artPath = path;
loadArt(path);
}
} catch (Exception e) {
Log.e(TAG, "bad media payload", e);
return;
}
Notification n = new NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_popup_sync)
.setContentTitle(title)
.setContentText(text)
.setOngoing(true)
.setContentIntent(contentIntent)
if (playing) {
requestFocus();
registerNoisy();
} else {
unregisterNoisy();
}
updateSession();
}
// --- MediaSession ------------------------------------------------------
private void createSession() {
session = new MediaSession(this, "YellowJacket");
session.setFlags(MediaSession.FLAG_HANDLES_MEDIA_BUTTONS
| MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS);
session.setCallback(new MediaSession.Callback() {
@Override
public void onPlay() {
emitCommand("play");
}
@Override
public void onPause() {
emitCommand("pause");
}
@Override
public void onStop() {
emitCommand("stop");
}
@Override
public void onSkipToNext() {
emitCommand("next");
}
@Override
public void onSkipToPrevious() {
emitCommand("previous");
}
@Override
public void onSeekTo(long pos) {
try {
JSONObject o = new JSONObject();
o.put("command", "seek");
o.put("positionSec", pos / 1000L);
WailsBridge.emitFromService(COMMAND_EVENT, o.toString());
} catch (Exception e) {
Log.e(TAG, "seek command failed", e);
}
}
});
session.setActive(true);
}
private void updateSession() {
MediaMetadata.Builder meta = new MediaMetadata.Builder()
.putString(MediaMetadata.METADATA_KEY_TITLE, title)
.putString(MediaMetadata.METADATA_KEY_ARTIST, artist)
.putString(MediaMetadata.METADATA_KEY_ALBUM, album)
.putLong(MediaMetadata.METADATA_KEY_DURATION, durationMs);
if (art != null) {
meta.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, art);
}
session.setMetadata(meta.build());
// The position is an anchor, not a clock: the state carries the
// playback speed and the OS interpolates from here, which is why the
// Go side only pushes on a real state change or a seek.
PlaybackState state = new PlaybackState.Builder()
.setActions(PlaybackState.ACTION_PLAY
| PlaybackState.ACTION_PAUSE
| PlaybackState.ACTION_PLAY_PAUSE
| PlaybackState.ACTION_STOP
| PlaybackState.ACTION_SKIP_TO_NEXT
| PlaybackState.ACTION_SKIP_TO_PREVIOUS
| PlaybackState.ACTION_SEEK_TO)
.setState(playing ? PlaybackState.STATE_PLAYING : PlaybackState.STATE_PAUSED,
positionMs, playing ? 1.0f : 0.0f)
.build();
session.setPlaybackState(state);
}
// --- Notification ------------------------------------------------------
private void createChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
// LOW: a transport notification is a control surface, not news, and
// IMPORTANCE_DEFAULT would make a sound on every track change.
NotificationChannel ch = new NotificationChannel(
CHANNEL_ID, "Playback", NotificationManager.IMPORTANCE_LOW);
ch.setShowBadge(false);
nm.createNotificationChannel(ch);
}
private void goForeground() {
Notification n = buildNotification();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// MEDIA_PLAYBACK, not the scaffold's DATA_SYNC. It must match
// android:foregroundServiceType in the manifest, or
// startForeground throws; and on Android 14+ the declared type
// is what decides whether the service may start from the
// background at all.
startForeground(NOTIFICATION_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
} else {
startForeground(NOTIFICATION_ID, n);
}
// Restart if the OS kills us while still wanted.
return START_STICKY;
}
@SuppressWarnings("deprecation")
private Notification buildNotification() {
Notification.Builder b = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
? new Notification.Builder(this, CHANNEL_ID)
: new Notification.Builder(this);
b.setSmallIcon(android.R.drawable.ic_media_play)
.setContentTitle(title.isEmpty() ? getString(R.string.app_name) : title)
.setContentText(artist)
.setSubText(album)
.setOngoing(playing)
.setVisibility(Notification.VISIBILITY_PUBLIC)
.setContentIntent(launchIntent());
if (art != null) {
b.setLargeIcon(art);
}
b.addAction(new Notification.Action.Builder(
android.R.drawable.ic_media_previous, "Previous",
transportIntent(ACTION_PREVIOUS, 1)).build());
b.addAction(playing
? new Notification.Action.Builder(android.R.drawable.ic_media_pause, "Pause",
transportIntent(ACTION_PAUSE, 2)).build()
: new Notification.Action.Builder(android.R.drawable.ic_media_play, "Play",
transportIntent(ACTION_PLAY, 3)).build());
b.addAction(new Notification.Action.Builder(
android.R.drawable.ic_media_next, "Next",
transportIntent(ACTION_NEXT, 4)).build());
Notification.MediaStyle style = new Notification.MediaStyle()
.setShowActionsInCompactView(0, 1, 2);
if (session != null) {
style.setMediaSession(session.getSessionToken());
}
b.setStyle(style);
return b.build();
}
private PendingIntent transportIntent(String action, int requestCode) {
Intent i = new Intent(this, WailsForegroundService.class).setAction(action);
return PendingIntent.getService(this, requestCode, i, pendingIntentFlags());
}
private PendingIntent launchIntent() {
Intent launch = getPackageManager().getLaunchIntentForPackage(getPackageName());
if (launch == null) {
return null;
}
return PendingIntent.getActivity(this, 0, launch, pendingIntentFlags());
}
private int pendingIntentFlags() {
// Mandatory from S, unavailable before M.
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
: PendingIntent.FLAG_UPDATE_CURRENT;
}
// --- Cover art ---------------------------------------------------------
/**
* Decode the cover off the main thread and redraw when it lands. A track
* change must not wait on a JPEG, and the notification is correct without
* one — it simply has no image until this returns.
*/
private void loadArt(final String path) {
art = null;
if (path == null || path.isEmpty()) {
return;
}
artExecutor.execute(() -> {
Bitmap decoded = decodeScaled(path);
mainHandler.post(() -> {
// The track may have changed while we decoded.
if (!path.equals(artPath)) {
return;
}
art = decoded;
updateSession();
goForeground();
});
});
}
private Bitmap decodeScaled(String path) {
try {
BitmapFactory.Options bounds = new BitmapFactory.Options();
bounds.inJustDecodeBounds = true;
BitmapFactory.decodeFile(path, bounds);
int longest = Math.max(bounds.outWidth, bounds.outHeight);
int sample = 1;
while (longest / sample > ART_MAX_PX) {
sample *= 2;
}
BitmapFactory.Options opts = new BitmapFactory.Options();
opts.inSampleSize = sample;
return BitmapFactory.decodeFile(path, opts);
} catch (Throwable t) {
// OutOfMemoryError included: a missing cover is not a crash.
Log.w(TAG, "cover art decode failed: " + path, t);
return null;
}
}
// --- Audio focus -------------------------------------------------------
private void requestFocus() {
if (hasFocus || audioManager == null) {
return;
}
if (focusListener == null) {
focusListener = this::onFocusChange;
}
int result;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
AudioAttributes attrs = new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build();
// No setWillPauseWhenDucked: from Oreo the framework ducks us
// itself and reports no CAN_DUCK loss, so the Go-side duck below
// is a pre-Oreo path. Asking to be told instead would mean
// pausing for every notification tone.
focusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(attrs)
.setOnAudioFocusChangeListener(focusListener, mainHandler)
.build();
result = audioManager.requestAudioFocus(focusRequest);
} else {
result = requestFocusLegacy();
}
hasFocus = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED;
}
@SuppressWarnings("deprecation")
private int requestFocusLegacy() {
return audioManager.requestAudioFocus(focusListener,
AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN);
}
@SuppressWarnings("deprecation")
private void abandonFocus() {
if (!hasFocus || audioManager == null) {
return;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && focusRequest != null) {
audioManager.abandonAudioFocusRequest(focusRequest);
} else {
audioManager.abandonAudioFocus(focusListener);
}
hasFocus = false;
}
private void onFocusChange(int change) {
switch (change) {
case AudioManager.AUDIOFOCUS_LOSS:
// Someone else owns the output now, for good.
hasFocus = false;
pausedByFocusLoss = false;
emitCommand("pause");
break;
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
// A phone call. Remember that the pause was ours to undo.
pausedByFocusLoss = playing;
emitCommand("pause");
break;
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
emitDuck(true);
break;
case AudioManager.AUDIOFOCUS_GAIN:
hasFocus = true;
emitDuck(false);
if (pausedByFocusLoss) {
pausedByFocusLoss = false;
emitCommand("play");
}
break;
default:
break;
}
}
// --- Noisy (headphones) ------------------------------------------------
private void registerNoisy() {
if (noisyRegistered) {
return;
}
registerReceiver(noisyReceiver,
new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY));
noisyRegistered = true;
}
private void unregisterNoisy() {
if (!noisyRegistered) {
return;
}
try {
unregisterReceiver(noisyReceiver);
} catch (IllegalArgumentException ignored) {
// Already gone; nothing to undo.
}
noisyRegistered = false;
}
// --- Talking to Go -----------------------------------------------------
private void emitCommand(String command) {
try {
JSONObject o = new JSONObject();
o.put("command", command);
WailsBridge.emitFromService(COMMAND_EVENT, o.toString());
} catch (Exception e) {
Log.e(TAG, "command emit failed: " + command, e);
}
}
private void emitDuck(boolean on) {
try {
JSONObject o = new JSONObject();
o.put("command", "duck");
o.put("on", on);
WailsBridge.emitFromService(COMMAND_EVENT, o.toString());
} catch (Exception e) {
Log.e(TAG, "duck emit failed", e);
}
}
@Override
public void onDestroy() {
running = false;
unregisterNoisy();
abandonFocus();
if (session != null) {
session.setActive(false);
session.release();
session = null;
}
artExecutor.shutdownNow();
super.onDestroy();
}
@Nullable
+8 -4
View File
@@ -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.
+49 -1
View File
@@ -60,6 +60,45 @@ need_sdk() {
[ -x "$EMULATOR" ] || die "no emulator at $EMULATOR — run 'make android-setup'"
}
# Address one device explicitly, because a bare `adb` addresses whatever
# is attached and there is very often something else attached: another
# project's emulator, or this one's own corpse left `offline` by a
# previous run. Both make every adb call here fail with "more than one
# device", which cmd_install then reports as "no device — run 'make
# android-emulator' first" *immediately after* that succeeded.
#
# The AVD name is the identity, not the serial: serials are assigned in
# boot order and change between runs. ANDROID_SERIAL is honoured if the
# caller set it, and is what every later `adb` in this script reads.
pick_device() {
[ -n "${ANDROID_SERIAL:-}" ] && return 0
online=$("$ADB" devices | awk '$2 == "device" { print $1 }')
[ -n "$online" ] || return 1
for serial in $online; do
name=$("$ADB" -s "$serial" shell getprop ro.boot.qemu.avd_name 2>/dev/null | tr -d '\r')
[ -n "$name" ] || name=$("$ADB" -s "$serial" shell getprop ro.kernel.qemu.avd_name 2>/dev/null | tr -d '\r')
if [ "$name" = "$AVD" ]; then
export ANDROID_SERIAL="$serial"
return 0
fi
done
# No AVD of ours, but exactly one device: a physical phone, which is
# the one target this tier actually wants (see android-tier.md).
if [ "$(printf '%s\n' "$online" | wc -l)" -eq 1 ]; then
export ANDROID_SERIAL="$online"
return 0
fi
echo "android: several devices and none is the '$AVD' AVD:" >&2
"$ADB" devices | sed '1d;/^$/d;s/^/ /' >&2
echo " set ANDROID_SERIAL to choose one" >&2
return 1
}
# The emulator is the only long-lived process here, and it is addressed
# by its saved pid. Never by name: `pkill -f emulator` matches this
# script's own command line and kills the shell running it, which is
@@ -126,6 +165,7 @@ cmd_start() {
echo -n "waiting for boot"
"$ADB" wait-for-device >/dev/null 2>&1 || die "device never appeared; see $LOGFILE"
pick_device || die "the emulator booted but could not be addressed"
for _ in $(seq 1 150); do
if [ "$("$ADB" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = "1" ]; then
echo " ok"
@@ -161,6 +201,7 @@ cmd_stop() {
cmd_install() {
need_sdk
[ -f bin/yellowjacket.apk ] || die "no bin/yellowjacket.apk — run 'make android' first"
pick_device || die "no device — run 'make android-emulator' first"
"$ADB" get-state >/dev/null 2>&1 || die "no device — run 'make android-emulator' first"
# The two ways this fails are both about identity rather than the
@@ -187,7 +228,12 @@ cmd_install() {
echo "or build with a version:"
echo " YJ_VERSION=1.3.1 YJ_VERSION_CODE=10301 make android"
;;
*INSTALL_FAILED_UPDATE_INCOMPATIBLE* | *signatures do not match*)
# The inner quotes are load-bearing: `do` is a reserved word, and
# an unquoted one in a case pattern is a syntax error that fails
# the parse of the *whole file* -- so every subcommand here died
# with "line 190: syntax error near unexpected token `do'", not
# just install.
*INSTALL_FAILED_UPDATE_INCOMPATIBLE* | *"signatures do not match"*)
echo
echo "The installed copy was signed with a different key. Android"
echo "never allows that as an update — which is exactly why CI"
@@ -202,6 +248,7 @@ cmd_install() {
cmd_launch() {
need_sdk
pick_device || die "no device — run 'make android-emulator' first"
"$ADB" shell am force-stop "$PKG"
"$ADB" logcat -c
"$ADB" shell am start -n "$PKG/$ACTIVITY" >/dev/null
@@ -209,6 +256,7 @@ cmd_launch() {
cmd_logs() {
need_sdk
pick_device || die "no device — run 'make android-emulator' first"
# The app's own tags plus the two that report its death. Chasing a
# raw logcat here is hopeless: the emulator emits thousands of lines
# a second, almost all of them WindowManager transitions.