Compare commits
5
Commits
ced537ecf2
...
df2e9ea777
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df2e9ea777 | ||
|
|
c99c8efa11 | ||
|
|
904786b941 | ||
|
|
b6651310ea | ||
|
|
da38b865fc |
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user