Merge remote-tracking branch 'origin/main' into wails-v3

This commit is contained in:
2026-08-17 08:29:21 -04:00
96 changed files with 11952 additions and 90 deletions
+427
View File
@@ -0,0 +1,427 @@
name: Build & publish the Android APK
# The fifth workflow, and the second that publishes. It builds a signed
# 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.
#
# **Why its own file rather than a job in ci.yml.** `ci.yml` runs on
# every branch push and is the workflow that gates; this one runs on
# tags only, takes tens of minutes on a cold cache, and the runner has
# capacity 1. Hanging it off the gate would put every push behind an
# SDK download.
#
# **Why it is keyed on the tag.** The ljos pipeline this is modelled on
# computes a version in CI and cuts the release itself, then gates the
# Android job on `needs.release.outputs.version != ''` with an
# `always()` whose absence silently kills the manual path. This repo
# has no release automation — tags are pushed by hand and
# homebrew-formula.yml already keys on `v*` — so the tag *is* the
# version and none of that machinery, or its failure modes, is needed.
#
# It deliberately does **not** carry `continue-on-error`. In ljos the
# Android job shared a pipeline with a server deploy that must never go
# red over a phone build; here it is standalone and can neither delay
# nor redden anything, so a release step that fails silently would be
# strictly worse than one that fails visibly.
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
version:
description: "Version to build (default: the latest v* tag)"
required: false
concurrency:
group: android-${{ github.ref }}
cancel-in-progress: true
jobs:
apk:
runs-on: ubuntu-latest
timeout-minutes: 60
container:
image: ubuntu:24.04
# /cache/tool holds the Go toolchain ci.yml already downloads.
# The other three are this workflow's own and are ~4 GB between
# them, which is most of its wall clock on a cold run:
# android-sdk the SDK, the NDK and the platform (~2 GB)
# gradle GRADLE_USER_HOME — the wrapper distribution and
# the AGP dependency graph (~700 MB)
# pnpm-store shared with ci.yml
# Every path must be inside the runner's `valid_volumes` allowlist:
# a directory outside it makes the job **fail to start**, rather
# than silently skipping the mount.
volumes:
- /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool
- /home/logan/docker/gitea/data/runner/cache/android-sdk:/cache/android-sdk
- /home/logan/docker/gitea/data/runner/cache/gradle:/cache/gradle
- /home/logan/docker/gitea/data/runner/cache/pnpm-store:/cache/pnpm-store
env:
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
OWNER: ${{ github.repository_owner }}
SHA: ${{ github.sha }}
REF_NAME: ${{ github.ref_name }}
DEBIAN_FRONTEND: noninteractive
GO_VERSION: '1.25.0'
npm_config_store_dir: /cache/pnpm-store
# The Go half wants the NDK; the Gradle half wants a platform.
ANDROID_HOME: /cache/android-sdk
ANDROID_SDK_ROOT: /cache/android-sdk
GRADLE_USER_HOME: /cache/gradle
# Pinned, not "whatever sdkmanager installs": newer NDKs have
# broken the Wails Android build before, and r26d is what plan
# 015 phase 0 was verified against.
NDK_VERSION: 26.3.11579264
# The registry package name. Obtainium watches
# <server>/api/packages/<owner>/generic/yellowjacket-android/latest/yellowjacket.apk
PACKAGE_NAME: yellowjacket-android
steps:
# libgtk-4-dev and libwebkitgtk-6.0-dev are here even though
# nothing in this job builds a desktop app: `wails3` is the task
# runner the whole Android build goes through, and the CLI links
# the GTK/WebKit bindings, so `go tool wails3` cannot compile
# without them. libasound2-dev is oto's `pkg-config -- alsa`
# probe, for the same reason (the *Android* build uses oboe, not
# ALSA — this is the host toolchain only).
- name: System packages
run: |
set -eu
apt-get update -qq
apt-get install -y -qq --no-install-recommends \
ca-certificates curl git jq unzip zip \
build-essential pkg-config \
libwebkitgtk-6.0-dev libgtk-4-dev libasound2-dev \
openjdk-21-jdk-headless
# By hand rather than actions/checkout: that is a JS action and
# needs node inside the container before any step has installed
# it. Same approach as the other four workflows.
- name: Clone repo at this commit
run: |
set -eu
git clone --quiet \
"https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" /src
git -C /src checkout --quiet --detach "$SHA"
git config --global --add safe.directory /src
git -C /src log --oneline -1
# A tag push carries the version in its own name. A manual run has
# no tag, so it takes the input or falls back to the latest v* tag,
# which is what a hand-triggered rebuild wants anyway.
- name: Resolve the version
id: version
working-directory: /src
run: |
set -eu
v="${{ inputs.version }}"
if [ -z "$v" ]; then
case "$REF_NAME" in
v*) v="$REF_NAME" ;;
*) v=$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null || echo "v0.0.0") ;;
esac
fi
v="${v#v}"
# Android orders releases by an integer and refuses anything
# not greater than what is installed. 1.3.1 -> 10301, which
# increases as long as minor and patch stay below 100.
IFS=. read -r maj min pat <<EOF
$v
EOF
code=$(( ${maj:-0} * 10000 + ${min:-0} * 100 + ${pat:-0} ))
if [ "$code" -le 0 ]; then
echo "refusing to build version '$v' (versionCode $code)" >&2
exit 1
fi
echo "version=$v" >> "$GITHUB_OUTPUT"
echo "code=$code" >> "$GITHUB_OUTPUT"
echo "building $v (versionCode $code)"
- name: Go toolchain
run: |
set -eu
if [ ! -x /cache/tool/go/bin/go ] || ! /cache/tool/go/bin/go version | grep -q "$GO_VERSION"; then
mkdir -p /cache/tool && rm -rf /cache/tool/go
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" | tar -C /cache/tool -xz
fi
echo "/cache/tool/go/bin" >> "$GITHUB_PATH"
/cache/tool/go/bin/go version
- name: Node toolchain
run: |
set -eu
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y -qq --no-install-recommends nodejs
corepack enable
node --version
# Idempotent by directory check. sdkmanager is itself idempotent
# but still spends minutes verifying, so the guards are what make
# this cheap on every run after the first.
- name: Android SDK and NDK (cached)
run: |
set -eu
mkdir -p "$ANDROID_HOME/cmdline-tools"
if [ ! -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
echo "command line tools: installing"
cd /tmp
curl -fsSL -o tools.zip \
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip -q tools.zip
rm -rf "$ANDROID_HOME/cmdline-tools/latest"
mv cmdline-tools "$ANDROID_HOME/cmdline-tools/latest"
else
echo "command line tools: cached"
fi
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
yes | sdkmanager --licenses >/dev/null 2>&1 || true
install_if_missing() {
if [ -d "$ANDROID_HOME/$2" ]; then
echo "$1: cached"
else
echo "$1: installing"
yes | sdkmanager --install "$1" >/dev/null
fi
}
# android-35 matches compileSdk/targetSdk in
# build/android/app/build.gradle. No system image and no
# emulator: this job builds, it does not run.
install_if_missing "platform-tools" "platform-tools"
install_if_missing "platforms;android-35" "platforms/android-35"
install_if_missing "build-tools;34.0.0" "build-tools/34.0.0"
install_if_missing "ndk;${NDK_VERSION}" "ndk/${NDK_VERSION}"
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/${NDK_VERSION}" >> "$GITHUB_ENV"
du -sh "$ANDROID_HOME" || true
# **Signing is not optional past the first install.** Android
# refuses to update an app whose signing key changed and the only
# remedy is an uninstall, which takes the user's library with it.
# build.gradle falls back to the *debug* keystore when these are
# absent, and that key differs between every machine and every
# runner — so publishing an unsigned build is a decision to
# reinstall by hand for ever. Fail instead.
# **Signing is not optional past the first install.** Android
# refuses to update an app whose signing key changed and the only
# remedy is an uninstall, which takes the user's library with it.
# build.gradle falls back to the *debug* keystore when these are
# absent, and that key differs between every machine and every
# runner — so publishing an unsigned build is a decision to
# reinstall by hand for ever. Fail instead.
#
# Decode, check and build are one step on purpose. Splitting them
# would mean either handing the password to a later step through
# `$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 APK
working-directory: /src
env:
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
YJ_VERSION: ${{ steps.version.outputs.version }}
YJ_VERSION_CODE: ${{ steps.version.outputs.code }}
run: |
set -eu
if [ -z "${KEYSTORE_B64:-}" ]; then
echo "ANDROID_KEYSTORE_B64 is not set."
echo
echo "Building without it signs with the debug key, and every future"
echo "update then fails with a signature mismatch. See"
echo "docs/android-release.md for the keytool command and the secrets."
exit 1
fi
if [ -z "${KEYSTORE_PASSWORD:-}" ]; then
echo "ANDROID_KEYSTORE_PASSWORD is not set — see docs/android-release.md" >&2
exit 1
fi
# The path is decided here rather than composed in an `env:`
# block: `${{ env.HOME }}` evaluates to an empty string in
# Gitea's expression context, which turns "$HOME/x.jks" into
# "/x.jks" — reported by Gradle as a missing file, a minute in.
keystore="${RUNNER_TEMP:-/tmp}/yellowjacket-release.jks"
printf '%s' "$KEYSTORE_B64" | base64 -d > "$keystore"
chmod 600 "$keystore"
# **A secret pasted into a web form very often carries a
# trailing newline**, and a password is compared byte for byte.
# Trim CR and LF from all three, and say so when it mattered —
# "the keystore did not open" with a correct password is an
# unpleasant thing to debug blind.
pass=$(printf '%s' "$KEYSTORE_PASSWORD" | tr -d '\r\n')
if [ "${#pass}" -ne "${#KEYSTORE_PASSWORD}" ]; then
echo "note: stripped newline(s) from ANDROID_KEYSTORE_PASSWORD"
fi
alias_want=$(printf '%s' "${KEY_ALIAS:-yellowjacket}" | tr -d '\r\n')
keypass=$(printf '%s' "${KEY_PASSWORD:-$pass}" | tr -d '\r\n')
# Describe the artifact before trying to open it. A truncated
# or mis-pasted base64 yields a file that is the wrong size or
# has no keystore header at all, and that is a different
# problem from a wrong password.
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" ;;
*) echo " WARNING: not a keystore header. Is the secret the base64 of the .jks?" ;;
esac
# Open it here rather than letting Gradle discover the problem
# at :app:validateSigningRelease, a minute of build time in and
# reported as a missing file rather than a bad password.
if ! keytool -list -keystore "$keystore" -storepass "$pass" >/tmp/ks.txt 2>/tmp/ks.err; then
echo "the keystore did not open with ANDROID_KEYSTORE_PASSWORD." >&2
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
exit 1
fi
echo "keystore opens with the supplied password"
# And check the alias now, for the same reason. It defaults to
# `yellowjacket`, so a keystore created with any other alias
# would otherwise fail deep inside Gradle.
if ! keytool -list -keystore "$keystore" -storepass "$pass" -alias "$alias_want" >/dev/null 2>&1; then
echo "alias '$alias_want' is not in this keystore. It holds:" >&2
sed -n 's/^\([^,]*\),.*Entry.*$/ \1/p' /tmp/ks.txt >&2
echo "Set ANDROID_KEY_ALIAS to one of those." >&2
exit 1
fi
echo "alias '$alias_want': present"
ANDROID_KEYSTORE_FILE="$keystore"
ANDROID_KEYSTORE_PASSWORD="$pass"
ANDROID_KEY_ALIAS="$alias_want"
ANDROID_KEY_PASSWORD="$keypass"
export ANDROID_KEYSTORE_FILE ANDROID_KEYSTORE_PASSWORD
export ANDROID_KEY_ALIAS ANDROID_KEY_PASSWORD
# ANDROID_SDK is passed explicitly: the Makefile defaults it to
# ~/Android/Sdk, which is the developer-machine layout and not
# this container's.
make android ANDROID_SDK="$ANDROID_HOME" ANDROID_NDK="$ANDROID_NDK_HOME"
- name: Verify the APK
id: apk
working-directory: /src
run: |
set -eu
apk=bin/yellowjacket.apk
[ -s "$apk" ] || { echo "no APK was produced" >&2; ls -la bin || true; exit 1; }
bt="$ANDROID_HOME/build-tools/34.0.0"
ls -la "$apk"
"$bt/aapt2" dump badging "$apk" | sed -n '1p;/application-label:/p;/native-code/p'
# 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 }}'" || {
echo "versionCode is not ${{ steps.version.outputs.code }}" >&2; exit 1; }
echo
"$bt/apksigner" verify --print-certs "$apk" |
grep -E 'Signer #1 certificate (DN|SHA-256 digest)'
# A build signed with the debug key installs once and can never
# be updated. It must never reach the registry.
if "$bt/apksigner" verify --print-certs "$apk" | grep -q 'CN=Android Debug'; then
echo "REFUSING TO PUBLISH: signed with the debug keystore" >&2
exit 1
fi
echo
echo "Record that SHA-256. If it ever changes, updates will fail."
# Two copies: a versioned one for history and a fixed `latest` URL
# for Obtainium to watch. Gitea refuses to overwrite an existing
# file, so `latest` is deleted first. Credentials are the same
# OWNER/PACKAGE_TOKEN pair arch-package.yml publishes with.
- name: Publish to the Gitea package registry
working-directory: /src
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -eu
base="${SERVER_URL}/api/packages/${OWNER}/generic/${PACKAGE_NAME}"
apk=bin/yellowjacket.apk
put() {
code=$(curl -s -o /tmp/put.out -w '%{http_code}' \
--user "${OWNER}:${PACKAGE_TOKEN}" \
--upload-file "$apk" "$1")
echo " -> $1 : $code"
# 409 is "already there", which is the correct outcome for a
# re-run of the same tag and not a failure.
if [ "$code" != "201" ] && [ "$code" != "409" ]; then
cat /tmp/put.out >&2
return 1
fi
}
echo "publishing the versioned copy"
put "$base/$VERSION/yellowjacket-$VERSION.apk"
echo "clearing the previous latest"
curl -s -o /dev/null -w ' -> delete latest: %{http_code}\n' \
--user "${OWNER}:${PACKAGE_TOKEN}" \
-X DELETE "$base/latest/yellowjacket.apk" || true
echo "publishing latest"
put "$base/latest/yellowjacket.apk"
echo
echo "Obtainium URL:"
echo " $base/latest/yellowjacket.apk"
+20 -6
View File
@@ -63,10 +63,24 @@ bin/
# packaging tasks that depend on it. A derived file with one source.
build/linux/yellowjacket.desktop
# `wails3 task common:update:build-assets` regenerates the mobile trees
# whether or not anything asks for them. This is a desktop player and
# cannot target iOS/Android, so their includes: entries are dropped from
# Taskfile.yml and the trees themselves are not carried — ignored rather
# than deleted-and-rediscovered on every asset refresh.
# iOS is not carried. `wails3 update build-assets` regenerates the tree
# whether or not anything asks for it, so it is ignored rather than
# deleted-and-rediscovered on every asset refresh, and its includes:
# entry is dropped from Taskfile.yml.
#
# build/android/ *is* carried — see plan 015. Note that `update
# build-assets` does NOT regenerate it (only `generate build-assets`
# does, and that rewrites the whole of build/), so the tree is committed
# and edited by hand like any other source. Only its output is ignored,
# below.
build/ios/
build/android/
# Android build output. jniLibs holds the ~30 MB per-ABI c-shared
# libraries the Go build produces; gen/ and overlay.json are written by
# `wails3 android overlay:gen`; the rest is Gradle's.
build/android/app/src/main/jniLibs/
build/android/app/build/
build/android/build/
build/android/.gradle/
build/android/gen/
build/android/overlay.json
+11
View File
@@ -29,6 +29,17 @@ linters:
- usetesting
- whitespace
- wsl_v5
exclusions:
paths:
# Wails scaffold, not ours. `build/android/` is generated by
# `wails3 generate build-assets` and carried verbatim (plan 015),
# and it contains one Go file -- scripts/deps/install_deps.go, the
# interactive SDK installer behind `task android:install:deps`.
# It trips 24 of the strict linters above, and reformatting
# upstream's file to our house style would be undone by the next
# refresh and would make the diff against upstream unreadable.
# `make android-setup` is what this repo uses instead.
- build/android/
formatters:
enable:
- gci
+16
View File
@@ -96,6 +96,15 @@ reference, because you need them *before* the failure, not after.
Run against the `bulk` seed a measurement session left behind and a
third of them fail (13 of 36, when it was measured), in a list that
reads exactly like a regression in whatever you are holding. `make dev-headless SEED=default` first.
- **The catalog is stubbed out locally now, like CI.**
`dev-headless.sh` defaults `YJ_CORE_INDEX_URL` to a dead address
because it was the only launcher that did not — `seed-sandbox.sh` and
`ci.yml` always have. Without it the app downloads the real ~1M-row
Explore catalog into the run's `YJ_HOME`, and specs that stage their
own catalog rows then search a million real ones and fail *locally
only*, which reads as a regression and is an environment. Pass
`YJ_CORE_INDEX_URL=<real url>` when you want the real catalog to
explore by hand.
- **…and the suite spends state it cannot always give back.**
`view-lifecycle.spec.ts` **skips an autotag album** on every run, out
of the eleven the seed has, and does not put it back — so around the
@@ -151,6 +160,7 @@ only climb when it cannot.
| Something you cannot predict — exploring | `make dev-headless SEED=default` + `playwright-cli` | interactive |
| Something whose answer is a *number*, not a pass | `make perf` against a bulk-seeded app | ~1 min + setup |
| A `.sql` or `.templ` file | `make generate`, then the checklist in [references/schema-change.md](references/schema-change.md) | |
| Anything that has to survive on a phone | `make android-smoke` against a booted emulator | ~1 min + setup |
Two targets are once-per-clone prerequisites that are **not**
dependencies of the targets needing them, so on a fresh checkout each
@@ -426,3 +436,9 @@ fails the build otherwise, including in files no lint pass compiles.
and what breaks in it.
- [schema-change.md](references/schema-change.md) — the two-file
schema/migration checklist.
- [android-tier.md](references/android-tier.md) — the emulator tier,
and the three reasons a failure there looks like a success. **Read
its first section before running anything on Android**: Go's stdout
does not reach logcat, `os.Exit` leaves no panic and no tombstone,
and ActivityManager restarts a dying app fast enough that `pidof`
always answers.
@@ -0,0 +1,350 @@
# The Android tier
A sixth tier, and the only one where **the app failing looks exactly
like the app working**. Read the first section before you run anything;
it is the difference between a diagnosis and an afternoon.
This tier answers "does the phone build run", nothing else. It is not a
spec tier, it does not run in CI, and the app is not a usable Android
player yet (plan 015 says why, at length).
## Three facts that make failure invisible
**Go's stdout does not reach logcat.** An Android app's fd 1 and 2 go to
`/dev/null`. Every `slog` line the app writes is discarded — including
the one naming the error it is about to exit on. `setprop
log.redirect-stdio true` does not help: it redirects the *Java*
runtime's `System.out`, and the Go code is a c-shared native library.
**`os.Exit` is a silent death.** `main()` ends several failure paths in
`os.Exit(1)`. From Android's side that is a process that vanished:
`ActivityManager: Process com.wails.app has died`, `Zygote: exited due
to signal 9`, and **no** panic, **no** `AndroidRuntime` stack, **no**
tombstone under `/data/tombstones` and nothing in `logcat -b crash` or
dropbox. All three of the places you would look are empty, and the one
signal that is present — SIGKILL — reads as "the system killed it",
which is the wrong hypothesis.
**ActivityManager restarts it, so a dead app looks alive.** A
crash-looping app is respawned several times a second, so `pidof` always
answers and `am start` always reports `Status: ok`. "Did it start" is
the wrong question. `make android-smoke` asks the right one — is it the
*same pid* a few seconds later.
The tell, once you know it: `I/WailsBridge: Wails bridge initialized`
followed immediately by a new pid doing the same thing. That means the
native library loaded, the JNI bridge came up, Go's `main()` ran, and
`main()` left. Work backwards through its `os.Exit(1)` paths.
## What to run
One-time, ~3.5 GB:
```bash
make android-setup # SDK pieces + the yj-test AVD, idempotent
```
Then:
```bash
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
make android-logs # filtered logcat, follow
make android-emulator-stop # console kill, then the saved PID
```
`make android-smoke SECONDS=30` for a longer window. On failure it
prints the last 40 app-relevant logcat lines and how to read them.
Never `pkill -f emulator` — the pattern matches the invoking shell's own
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.**
`/opt/android-sdk` (the `android-sdk` package) has an NDK and
build-tools but `platforms/` is *empty*, so Gradle fails with a
compileSdk error that reads like a version mismatch. The Makefile
defaults `ANDROID_SDK` to `~/Android/Sdk` (user-owned, writable,
where sdkmanager puts things) and `ANDROID_NDK` to `/opt/android-ndk`
separately, because the Go half wants the NDK and the Gradle half
wants the platform and they are in different places.
- **The NDK is pinned to r26d** (`26.3.11579264`, Arch's
`android-ndk-26`). Newer NDKs have broken the Wails Android build
before. CI pins the same one.
- **Without KVM the emulator still works and is unusably slow** — a 30 s
boot becomes tens of minutes, which reads as a hung target rather than
a slow one. `make android-setup` checks and warns.
- **`-no-snapshot` is deliberate.** A snapshot-resumed emulator carries
the previous run's app state, and a smoke result that depends on what
the last run left behind is not a result.
- **The logcat filter is not optional.** The emulator emits thousands of
lines a second, nearly all WindowManager transitions; an unfiltered
`adb logcat` buries the six lines that matter. `make android-logs`
filters to `WailsBridge`, the app's own tag, `GoLog`, `AndroidRuntime`,
`DEBUG` and `libc:F`.
- **`run-as` does not work on a release-signed APK** (`package not
debuggable`), so you cannot read the app's data directory or its
environment that way. Ask the device instead, or build a debug variant.
- **The `google_apis` system image, not `default`.** This app is a
WebView app; `google_apis` ships the Chrome-based WebView that
actually renders it.
## The current state of the build
**The app starts. The x86_64 emulator cannot run it, and that is not a
bug in the app.**
`modernc.org/libc` — which `modernc.org/sqlite`, and therefore the whole
database layer, sits on — issues a **raw `lstat` syscall on
linux/amd64** (`libc_linux_amd64.go`'s `Xlstat64` calls
`unix.Syscall(unix.SYS_LSTAT, …)`). Android's seccomp policy forbids
syscall 6 on x86_64, because bionic never issues it, so the process
takes `SIGSYS` the first time anything touches the database:
```
F/libc: Fatal signal 31 (SIGSYS), code 1 (SYS_SECCOMP), syscall 6
F/DEBUG: Cause: seccomp prevented call to disallowed x86_64 system call 6
```
**arm64 is unaffected, and structurally so.** There is no `lstat`
syscall on arm64 at all, so `ccgo_linux_arm64.go`'s `Xlstat` is
`Xfstatat(…, AT_SYMLINK_NOFOLLOW)` → `SYS_newfstatat` (79), which
Android permits. `grep -c SYS_LSTAT ccgo_linux_arm64.go` is 0. Go's own
`syscall` package already uses `fstatat` on both architectures, which
is why this is *only* the modernc path.
So: **verify on arm64, and on this machine that means a real device.**
`make android-smoke` on an x86_64 AVD reports a `SIGSYS` tombstone that
says nothing about your change.
**Do not reach for an arm64 system image — it will not run here, and
finding that out costs a 3.8 GB download.** Emulator 37 refuses
outright:
```
FATAL | Avd's CPU Architecture 'arm64' is not supported by the QEMU2
emulator on x86_64 host. System image must match the host
architecture.
```
Google dropped cross-architecture emulation; there is no flag. The
options are an arm64 host, a physical device, or `adb connect` to one.
**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
`backend/system`'s `buildUserDirPath` switched on `runtime.GOOS` with a
`default:` returning `errUnsupportedOS`, so Android failed at startup
and `main()` called `os.Exit(1)` six milliseconds after the bridge came
up. `main()` now calls `system.UseHomeOverride(application.Mobile.
StoragePath())` before anything asks for a path — a documented,
build-tag-free API that returns `""` on desktop, where the setter is a
no-op. `backend/system` gained no import of the Wails application
package, which matters for the same reason `backend/events` is split by
the `indexbuild` tag.
### What is still not done
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
`build/android/Taskfile.yml` ships more than the Makefile wraps, and
they are the right thing to reach for when you want something one-off:
```
wails3 task android:run # debug build + emulator install + launch
wails3 task android:run:device # same, first connected physical device
wails3 task android:deploy-device # production APK to a device
wails3 task android:bundle:fat # AAB, for a Play Store upload
wails3 task android:studio # open build/android/ in Android Studio
wails3 task android:device:list
wails3 task android:logs:all
wails3 task android:clean
```
Two are deliberately **not** wrapped. `android:logs` greps logcat for
`(Wails|yellowjacket)`, which catches the `WailsBridge` tag but misses
the app's own process tag (`app.yellowjacket` — lowercase, so `Wails`
does not match it) and misses `ActivityManager`'s "has died" line, which
is the one that tells you it crashed; `make android-logs` filters by tag
instead. And `ensure-emulator` boots whatever `-list-avds | tail -1`
returns, with no pidfile and no boot wait, so it cannot be stopped or
sequenced.
## The identity is declared twice
`applicationId` in `build/android/app/build.gradle` is what Gradle
installs. `APP_ID` in `build/android/Taskfile.yml` is what every
adb-driven task uninstalls, launches and filters. **Nothing enforces
that they agree**, and `ANDROID.md`'s advice to set `APP_ID` in
`build/config.yml` does not work in beta.8 — `wails3 task` never reads
that file (verified with `--dry`), and even when set it feeds only the
adb commands, never Gradle. Change both or the official `run`/`deploy`
tasks address a package that is not installed.
Related, and it will bite once: the launcher activity is
`com.wails.app.MainActivity` and the applicationId is
`app.yellowjacket`. `am start -n app.yellowjacket/.MainActivity`
resolves the leading dot against the *applicationId* and fails with a
class-not-found that reads like a broken build. Always the
fully-qualified form.
## What only a device can answer
The emulator cannot run this app (three separate reasons, none of them
ours — see plan 016), so the phone in someone's pocket is a tier, and
asking for it is cheap. The first run of it, on 2026-08-17, confirmed
the whole of A4 and found two faults **no other tier can see**:
- **The back gesture.** `MainActivity.onBackPressed` asks
`webView.canGoBack()`. Nothing in a desktop shell has a back gesture,
so no spec had ever called `page.goBack()` and the app had never
pushed a history entry — back quit from any depth. It is a history
entry per navigation now, which is also what made it assertable in the
browser tier (`e2e/specs/back-navigation.spec.ts`).
- **The safe area.** `targetSdk 35` forces edge-to-edge, so the
transport and the tab bar sat under the gesture bar. **A browser
viewport has no system bars**: `phone-shell.spec.ts` at 390x844 will
keep passing on a build the device is clipping 48dp off. Insets are
handled in `applyWindowInsets()`.
So when asking for a device run, ask about what the platform *adds* —
system bars, the back gesture, focus and audio interruptions,
permission dialogs, the keyboard — not about what the app draws. The
drawing is what the other five tiers already cover.
## Asking the device, not just looking at it
A real phone can be inspected, and that turns this tier from "reported
symptoms" into evidence. Three commands:
```bash
make android-screenshot # what the screen shows (.dev/ by default)
make android-inspect # forward the WebView's devtools socket
make android-eval EXPR='JSON.stringify({vp:[innerWidth,innerHeight]})'
```
Four things about it, each of which costs an hour if met cold:
- **Only a `debuggable` build has a devtools socket**, and a debug build
carries `applicationIdSuffix ".dev"` so it installs **beside** the
release app. That matters more than convenience: the two are signed by
different certificates, and Android's only remedy for a changed
certificate is an uninstall, which takes the user's library with it.
Never uninstall to make room for a build.
- **Playwright cannot drive it.** `connectOverCDP` calls
`Browser.setDownloadBehavior`, a WebView answers "Browser context
management is not supported", and the connection dies before the first
evaluate. `scripts/android-eval.mjs` is raw CDP over Node's built-in
WebSocket for that reason.
- **Wireless adb drops when the screen sleeps.** The symptoms are
`device offline` mid-session and a `fetch failed` from the eval
script. Plug in over USB for anything longer than a couple of probes.
- **The socket name carries the pid**, which changes on every launch, so
it is resolved rather than remembered.
**And the reason to bother: the phone is an engine, not a screen.** The
first device here renders in **Chrome 113** at 424x439 CSS px. Every
other tier runs a current Chromium or WebKit, so a spec that passes at
that viewport says nothing about the phone — 113 has no Popover API and
no relaxed CSS nesting, and a dropped CSS declaration renders as
"present but wrong", which is the hardest failure to read from a
picture. Get the version first; it reframes every other symptom.
+814
View File
@@ -2394,3 +2394,817 @@ And `tag_status` was only ever written by the *insert* path, so a file
another tagger stamped after import kept `untagged` for ever and its
folder kept asking; `updateAudioFile` promotes it now, guarded on
`untagged` so a deliberate `user_skipped_permanent` survives a rescan.
## Android cross-compiles, unchanged (measured 2026-08-16)
Plan 015's phase 0 gate, and it passed further than it was asked to: the
whole app builds for Android and produces a working 27 MB fat APK with
**no source changes at all**.
Environment: Arch's `android-ndk-26` (`/opt/android-ndk`, r26d /
26.3.11579264 — the pinned version), platform `android-35` and
build-tools 34.0.0 from `~/Android/Sdk`. Note that Arch's
`/opt/android-sdk` carries *no* platforms, so `ANDROID_HOME` has to
point at `~/Android/Sdk` for the Gradle half while `ANDROID_NDK_HOME`
points at `/opt/android-ndk` for the Go half.
```
export ANDROID_NDK_HOME=/opt/android-ndk
export ANDROID_HOME="$HOME/Android/Sdk" ANDROID_SDK_ROOT="$HOME/Android/Sdk"
cd frontend && pnpm build && cd .. # main.go embeds frontend/dist
PATH="$PWD/scripts/toolbin:$PATH" go tool wails3 task android:package:fat
```
Results, all first-try:
| | |
|---|---|
| `libwails.so` arm64-v8a | 29.9 MB, production, stripped |
| `libwails.so` x86_64 | 31.8 MB, production, stripped |
| `bin/yellowjacket.apk` | 27.3 MB, both ABIs |
| Go compile, per ABI | ~9 s |
| Gradle assemble | ~13 s cold |
**The dependency that looked fatal is fine.** A `CGO_ENABLED=0` probe of
`./backend/... ./internal/...` for `android/arm64` compiles *everything*
except two packages, and both fail only because their Android
implementation is cgo: `ebitengine/oto/v3` (`driver_android.go` needs its
bundled **oboe** C++ backend) and `wails/v3/pkg/application` (the JNI
bridge). Both are exactly what the NDK supplies. `modernc.org/sqlite` —
the whole database layer, and the thing most likely to have no Android
target — is clean. Confirmed in the linked object rather than inferred:
`nm -D` shows `oto_oboe_Play` and the `oboe::` symbols, `readelf -d`
shows `libOpenSLES.so` as NEEDED, and the
`Java_com_wails_app_WailsBridge_native*` exports are present. The audio
backend is genuinely linked, not stubbed.
Four things found on the way that are not obvious:
- **`wails3 update build-assets` does not generate `build/android/`.** In
beta.8 it extracts only `internal/commands/updatable_build_assets`,
which is darwin/ios/linux/windows. The android tree comes from
`generate build-assets`, which extracts the *whole* asset FS and would
rewrite all of `build/`. So it was generated into a scratch dir and
`android/` copied across. CLAUDE.md claimed the refresh regenerates it;
that was wrong, and is corrected.
- **`update build-assets` does clobber nfpm's `homepage` and
`license`**, which `build/linux/nfpm/nfpm.yaml` says in a comment it
leaves alone. It reset them to `https://wails.io` and `MIT`. The
comment is wrong; those two fields need re-checking after any refresh.
- **The scaffold's `package:fat` shipped a debug arm64 library.**
`build` forwards `ARCH` to `compile:go:shared` but not `PRODUCTION`,
so the arm64 leg recomputed `BUILD_FLAGS` against an unset
`.PRODUCTION` and took the debug branch — while amd64, which
`package:fat` calls directly with `PRODUCTION: "true"`, was correct.
A release APK therefore carried a 40 MB unstripped debug library for
the phone ABI and a 31 MB production one for the emulator. Fixed in
`build/android/Taskfile.yml`, which is this repo's one edit to that
scaffold file and is commented as such. 34 MB APK before, 27 after.
- **The generated APK is not yet an identity.** `com.wails.app`,
`versionCode 1`, `versionName 1.0`, signed `CN=Android Debug`. That is
plan 015 phase 2 and none of it is a surprise, but it is worth knowing
that the scaffold happily produces an installable-once,
never-updatable APK by default.
**Not established:** that it *runs*. There is no AVD or system image on
this machine and no device attached, so nothing has launched the APK.
Every runtime concern plan 015 lists as out of scope is still out of
scope and still real — MPRIS in particular is compiled *in*, because
Go's `android` GOOS implies the `linux` build tag.
## The Android build runs, and stops on one line (measured 2026-08-16)
The APK installs and launches on an emulator. `libwails.so` loads, the
JNI bridge comes up — and the process is gone six milliseconds later.
**The cause is `backend/system/buildUserDirPath`.** It switches on
`runtime.GOOS` with cases for `darwin`, `linux` and `windows` and a
`default:` returning `errUnsupportedOS`. `runtime.GOOS` is `"android"`,
so it takes the default, `NewYellowJacketApp` fails, and `main()` calls
`os.Exit(1)`. `YJ_HOME` overrides that path on every OS, so an
`android` case pointing at the app-private directory is the shape of
the fix. It is the *first* thing that stops it, not the only one.
**What cost the time was not finding the bug, it was that the failure
is invisible in all three places you would look.** Worth knowing before
meeting it:
- **Go's stdout does not reach logcat.** An app's fd 1 and 2 go to
`/dev/null`, so the `slog` line naming the error is discarded.
`setprop log.redirect-stdio true` does not help — that redirects the
*Java* runtime's `System.out`, not a c-shared native library's.
- **`os.Exit` leaves no evidence.** No panic, no `AndroidRuntime`
stack, nothing in `/data/tombstones`, nothing in `logcat -b crash` or
dropbox. The only signal present is `Zygote: exited due to signal 9`,
which reads as "the system killed it" and sends you looking at the
low-memory killer.
- **ActivityManager restarts it faster than you can observe it.**
`pidof` always answers and `am start` always says `Status: ok`, so
the app looks alive while crash-looping several times a second. The
honest check is whether it is the *same pid* a few seconds later,
which is what `make android-smoke` asserts.
The tell is `I/WailsBridge: Wails bridge initialized` followed
immediately by a new pid doing the same thing.
**Emulator environment**, which is not the obvious one on Arch: Gradle
needs a *platform*, and `/opt/android-sdk` (the `android-sdk` package)
has an NDK and build-tools but an empty `platforms/`. So `ANDROID_HOME`
points at `~/Android/Sdk` (user-owned, where sdkmanager writes) while
`ANDROID_NDK_HOME` points at `/opt/android-ndk` — two SDKs, one for
each half of the build. The image is
`system-images;android-35;google_apis;x86_64` (~3.5 GB with the
emulator sdkmanager pulls alongside it): `google_apis` rather than
`default` because this is a WebView app and that image carries the
Chrome-based WebView. KVM is present and usable here; without it a 30 s
boot becomes tens of minutes, which reads as a hung target.
Operating all of this is `scripts/android-emulator.sh` and the
`make android-*` targets, documented in
`.pi/skills/yellowjacket-dev/references/android-tier.md`.
## What the Wails v3 Android docs say, and where they are wrong (2026-08-16)
Read after phase 0, before phase 2. Sources: `ANDROID.md` shipped inside
`wails/v3@v3.0.0-beta.8` (authoritative for our exact version) and
`v3.wails.io/guides/mobile/*`.
**Two claims in `ANDROID.md` are wrong for beta.8, and both were
checked.** Its Configuration section says to put `APP_ID: com.example.
myapp` in `build/config.yml` and that this "controls the package name".
Neither half holds. `wails3 task` builds its variable set from CLI
`KEY=VALUE` arguments and the Taskfile tree and **never reads
`config.yml`** (`internal/commands/task.go`); adding `APP_ID` there and
running `android:run:device --dry` still emits
`am start -n com.wails.app/`. And `APP_ID` feeds only the adb commands
in the android Taskfile — uninstall, launch, log filter — never Gradle,
whose `applicationId` is a literal in `app/build.gradle`. So the
identity is necessarily declared **twice** and nothing enforces
agreement. Both are set now, each with a comment pointing at the other.
**The fix for the crash we found is a documented API.**
`application.Mobile.StoragePath()` returns the app's private internal
files directory (`getFilesDir()` on Android, Application Support on
iOS) and — the useful part — is **build-tag-free**: `mobile.go` declares
the interface and `mobile_stub.go` returns `""` on desktop. Since
`resolveUserDirPath` already lets `YJ_HOME` override the path on every
OS, the whole fix is to set that override from `StoragePath()` early in
`main()` when it is non-empty. No `//go:build` split, no new import in
`backend/system` (which must stay Wails-free — the `indexbuild` tag
split exists for exactly that), and desktop behaviour is untouched
because the stub returns empty.
The same section gives the general rule: branch on
`application.System.IsMobile()` / `IsPlatform(application.PlatformAndroid)`
rather than build tags, because it compiles everywhere.
**`android` implies `linux` is documented**, which confirms rather than
discovers the MPRIS problem: `//go:build linux` files are in the Android
build and desktop-Linux-only ones need `linux && !android`.
**A finding for the runtime plan, not this one: the folder picker does
not exist on Android.** Open-*directory* dialogs "return an error — SAF
yields tree URIs, not filesystem paths", and save-file dialogs likewise.
This app's entire first run is "choose your music folder", and its
library model is filesystem paths. That is a design problem, not a
porting detail, and it is larger than the data-directory one.
**The scaffold ships its own android tasks**, and they are worth knowing
before writing anything: `android:run`, `run:device`, `deploy-emulator`,
`deploy-device`, `package`, `package:fat`, `bundle`/`bundle:fat` (AAB
for Play), `studio`, `device:list`, `logs`, `logs:all`, `clean`, and an
internal `ensure-emulator`. `make android-*` deliberately does not wrap
most of them. Two reasons it does not just use `android:logs`: that task
greps logcat for `(Wails|yellowjacket)`, which matches the `WailsBridge`
tag but **not** the app's own process tag (`app.yellowjacket`, lowercase)
and **not** `ActivityManager`'s "has died" line — the one that tells you
it crashed. And `ensure-emulator` takes whatever `-list-avds | tail -1`
returns, with no pidfile and no boot wait, so it cannot be stopped or
sequenced by a Makefile.
Two smaller things. Debug builds log framework diagnostics to logcat
under the `Wails` tag and are inspectable from `chrome://inspect`;
production builds compile that out — so a debug APK is the more
informative one when something is wrong. And the docs recommend
`build-tools;35.0.0`; 34.0.0 is what is installed here and builds fine.
## The app starts on Android; x86_64 Android cannot run it (2026-08-16)
Two findings, and the second is the one with consequences.
**The startup bug is fixed.** `backend/system`'s `buildUserDirPath`
switched on `runtime.GOOS` and Android took the `default:` branch, so
`main()` called `os.Exit(1)` six milliseconds after the JNI bridge came
up. `main()` now calls
`system.UseHomeOverride(application.Mobile.StoragePath())` before
anything asks for a path. `StoragePath()` is `getFilesDir()` on
Android, Application Support on iOS and `""` on desktop — where
`UseHomeOverride` is a no-op — so the change needs no build tag and
alters nothing off mobile. `backend/system` gained no import of the
Wails application package, deliberately: that is the same constraint
the `indexbuild` split protects in `backend/events`.
**And then it takes SIGSYS on the x86_64 emulator.**
```
F/libc: Fatal signal 31 (SIGSYS), code 1 (SYS_SECCOMP), syscall 6
F/DEBUG: Cause: seccomp prevented call to disallowed x86_64 system call 6
```
Syscall 6 on x86_64 is `lstat`, and the caller is **not our code and
not Go's**. Go's `syscall` package already routes both `Stat` and
`Lstat` through `fstatat` on amd64 *and* arm64. The caller is
`modernc.org/libc`, which `modernc.org/sqlite` sits on and therefore
the entire database layer: `libc_linux_amd64.go`'s `Xlstat64` issues
`unix.Syscall(unix.SYS_LSTAT, …)` directly. Android's seccomp filter
forbids it because bionic never issues it.
**arm64 is unaffected, structurally rather than by luck.** arm64 has no
`lstat` syscall at all, so `ccgo_linux_arm64.go`'s `Xlstat` is
`Xfstatat(…, AT_SYMLINK_NOFOLLOW)` → `SYS_newfstatat` (79), which is
permitted. `grep -c SYS_LSTAT ccgo_linux_arm64.go` returns 0 against 1
for amd64.
Three consequences:
- **The default emulator cannot verify this app.** `make android-smoke`
on an x86_64 AVD reports a tombstone that says nothing about your
change. Verification needs an `arm64-v8a` image (full software
emulation on an x86_64 host, so slow) or a real device.
- **The x86_64 half of the fat APK is dead weight on every Android**,
not just emulators — an x86 Chromebook would hit exactly this. It is
31 MB of a 27 MB compressed artifact. Dropping it is a real option;
keeping it costs size and buys an emulator target that does not work.
Not decided here.
- The failure is at least *legible*. Unlike the `os.Exit` it replaced,
SIGSYS leaves a tombstone with a backtrace into `libwails.so`, which
is how it was identified in one pass.
Worth knowing for anything else that reaches for a pure-Go C library:
this class of bug is invisible to every build and every desktop test,
and appears only under a platform's syscall filter.
### …and the arm64 emulator is not an option on an x86_64 host
Emulator 37.1.11 refuses outright, after the 3.8 GB image download:
```
FATAL | Avd's CPU Architecture 'arm64' is not supported by the QEMU2
emulator on x86_64 host. System image must match the host
architecture.
```
Google dropped cross-architecture emulation and there is no flag for
it. So the arm64 claim above rests on reading modernc's two code paths,
not on having run it: verifying the shipped ABI needs an arm64 host, a
physical device, or `adb connect` to one. The image was deleted again;
do not re-download it.
## Android media controls need no new JNI and no new dependency (2026-08-16)
Plan 016's A4 — playback that survives the screen locking — turned out
to be reachable entirely through seams that already exist, which is the
finding worth keeping. The obvious blocker is that Wails' `androidBridge*`
helpers are unexported, so Go cannot call arbitrary Java. It does not
need to:
- **Go → Java** is `application.Android.StartForegroundService(json)`,
which *is* exported, and `build/android/` is our tree — so widening
the JSON that `WailsBridge.startForegroundService` accepts is a local
edit, not a fork of the runtime.
- **Java → Go** is `WailsBridge.emitEvent(name, json)` →
`nativeEmitEvent` → `app.Event.Emit`, which a Go `app.Event.On`
subscriber receives with `Data` as a `map[string]any`.
So the handler is one JSON document out and one command event back, and
`backend/mediacontrols`' existing `Handler`/`Callbacks` interface — written
for MPRIS — needed one addition (`OnDuck`) to cover a MediaSession.
**The Java side needs no androidx.media either.** `MediaSessionCompat`
is the documented route, but `android.media.session.MediaSession` and
`Notification.MediaStyle` are both API 21 and minSdk here is 21, so the
platform API covers it with two `Build.VERSION` branches (the channel,
and PendingIntent mutability flags) and no new Gradle dependency.
Four things measured or reasoned along the way, each of which would
have been a bug:
- **From API 26 the framework ducks the app itself** and sends no
`AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK`. So a duck implemented in the
player is a *pre-Oreo* path, and `setWillPauseWhenDucked(true)` —
which is how you get the callback back — would mean pausing for
every notification tone. Implementing both attenuates twice.
- **A duck must not touch the user's volume.** `Player.SetDuck` holds
the attenuation as a separate offset and re-applies the user's level
through `setVolumeLocked`, so it cannot accumulate across repeated
ducks and `getUserVolume` — which feeds the event, the persisted
state and every relative change — still reports what the user chose.
- **From Android 12 a background app may not *start* a foreground
service**, but it may keep delivering intents to one already running.
Every update after the first is exactly that case (a track change
with the screen off), so `WailsBridge` picks `startService` over
`startForegroundService` once `WailsForegroundService.running` is set.
- **A service started with `startForegroundService` that returns from
`onStartCommand` without calling `startForeground` is killed**, so
the transport-button intents call it too rather than only the payload
path.
**`make lint` does not see any of this.** Its three passes are the app,
`indexbuild` and `dev` tag sets, all on linux/amd64, and `android.go` is
behind the `android` build tag — the only thing that compiles it is the
cross-compiler in `make android`. That is why the payload keys, the
state words and the command names live in `androidpayload.go` *without*
a build tag, with a test: it is the half that can be checked on the
machine doing the work. A quick manual check of the tagged half is
```bash
B=$(echo /opt/android-ndk/toolchains/llvm/prebuilt/*/bin)
CC=$B/aarch64-linux-android21-clang CXX=$B/aarch64-linux-android21-clang++ \
GOOS=android GOARCH=arm64 CGO_ENABLED=1 go build ./backend/...
```
— `CXX` matters: without it the oboe C++ sources compile against the
host sysroot and fail on `android/log.h`, which reads like a missing NDK.
**None of it has run.** The APK builds for both ABIs and the Go and Java
halves compile; everything above about behaviour is read from the
Android documentation and the source. The x86_64 emulator still cannot
run this app (modernc `lstat`/seccomp, above) and an arm64 AVD still
cannot exist on an x86_64 host, so A4's first real test is a device.
## Dropping x86_64 cut the APK by 41% (measured 2026-08-16)
Plan 016's B1, decided: the ABI is gone.
| | fat (arm64 + x86_64) | arm64 only |
|---|---|---|
| `bin/yellowjacket.apk` | 27,059,130 B | 15,898,465 B |
| `lib/` entries | 2 | 1 |
It buys nothing to keep. x86_64 Android takes SIGSYS the first time it
touches the database (modernc's raw `lstat` against Android's seccomp
filter, above), which is *every* x86_64 device — emulators and x86
Chromebooks alike — not merely the emulator here.
Three places had to agree, and the third is the one that would have
made this a silent no-op: `abiFilters` in `build/android/app/
build.gradle` (what Gradle packages), `android:package` rather than
`android:package:fat` in the Makefile (what Go compiles — otherwise the
31 MB library is still built and then discarded), and the `native-code`
assertion in `android-apk.yml`'s Verify step, which is now
`native-code: 'arm64-v8a'$` and fails if a second ABI ever comes back.
The anchor is deliberate and was checked against a real artifact:
without it the pattern also matches the fat APK's line.
One consequence for the dev tier was written down before it was
checked, and checking it proved it false — see the next entry.
## arm64 translation runs Go until Go asks the CPU what it is (measured 2026-08-16)
Predicted, when the x86_64 ABI was dropped: `make android-install`
against the emulator would now fail with
`INSTALL_FAILED_NO_MATCHING_ABIS`. **Measured: it installs and
launches.** Google's `google_apis` x86_64 images carry arm64
translation —
```
ro.product.cpu.abilist = x86_64,arm64-v8a
```
— so the loader maps `lib/arm64/libwails.so` and executes it; the
tombstone confirms it with `ABI: 'x86_64'` / `Guest architecture:
'arm64'`.
It dies anyway, before a line of our code, and the instruction says
exactly why. The fault is at `libwails.so+0x15911d0`:
```
signal 4 (SIGILL), code -6 (SI_TKILL)
15911d0: d5380600 mrs x0, ID_AA64ISAR0_EL1
```
That is Go's `internal/cpu` reading the arm64 feature-ID system
register during runtime init. The translator does not implement it, so
**no Go binary starts under it** — this is not a property of this app
and no work here would change it. (`code -6 (SI_TKILL)` also means the
signal was re-raised by the process itself: Go's handler caught the
SIGILL, printed a traceback to a stdout that goes to `/dev/null`, and
re-raised. The invisible-failure rule again.)
So there are now three distinct ways this app fails on an x86_64
Android, none of them a bug in it:
| build | cause | signal |
|---|---|---|
| x86_64 | modernc's raw `lstat` vs seccomp | SIGSYS, syscall 6 |
| arm64, translated | Go reads `ID_AA64ISAR0_EL1` | SIGILL |
| arm64, real device | — | still unverified |
**A physical arm64 device is still the only verification path**, which
is the conclusion the previous session reached by a different route.
The value of this entry is that it closes the remaining plausible
shortcut, with the instruction that closes it.
### Two bugs the attempt found in the harness itself
Both were on `main`, and the first had made the whole tier unusable
since the commit that added it.
**`scripts/android-emulator.sh` did not parse.** A `case` pattern read
`*signatures do not match*)`, and `do` is a reserved word: bash fails
the parse of the *entire file*, so `make android-emulator`,
`android-install`, `android-smoke` and `android-logs` all died with
`line 190: syntax error near unexpected token 'do'`. Quoting the inner
words fixes it. A shell script that is only run interactively can carry
a syntax error indefinitely — `bash -n` in the pre-commit hook would
have caught it, and does not exist.
**A bare `adb` addresses whatever is attached.** With a second emulator
present (another project's, or a stale `offline` entry from a previous
run), every adb call fails with "more than one device", and
`cmd_install` reported that as *"no device — run 'make
android-emulator' first"* — directly after that had printed "waiting
for boot ok". `pick_device` now resolves `ANDROID_SERIAL` from
`ro.boot.qemu.avd_name`, since serials are assigned in boot order and
the AVD name is the stable identity. Verified with both emulators
running: it selects `yj-test` and installs.
## The phone shell fits, and what it cost to make it fit (2026-08-16)
Plan 016 B2, phase 1: the shell below 600px. Measured at 360×780 and
390×844 against the real app (`make dev-headless` + Playwright, which
is the tier that can answer this — server mode serves the same document
an Android WebView renders).
**What overflowed, and by how much.** The body was 652px wide in a
360px viewport before any of this. Walking every element and its shadow
roots for a `right` past the viewport named the causes in order:
| element | width | why |
|---|---|---|
| `header.top-bar` | 580 | its children's minimums, summed |
| `search-bar` | 320 | `.search-container { min-width: 200px }` |
| `job-indicator` | 157 | the label, "3 background jobs" |
A `min-width` in a flex row is a *hard* floor — it does not shrink — and
a grid item's implicit minimum is `auto`, i.e. its content. So the
header could not get smaller than the sum of what it held, the body grew
to the header, and `overflow-x: hidden` would then have hidden a third
of the app rather than fitting it. `min-width: 0` on the boxes between
the viewport and the content, plus each component standing its own
non-essential parts down in its own stylesheet, takes 360 → 360 exactly.
At 320px (400% zoom, the width WCAG 1.4.10 names) it is also exact.
**So an existing spec now asserts the opposite of what it did**, and
that is the fix landing rather than the test being weakened.
`layout-overflow.spec.ts` used to assert that the 464px of app behind
`overflow: hidden` *could be scrolled to* with a wheel gesture, which
was the remedy available when the shell had one layout. It reflows now,
which is what 1.4.10 asks for; scrolling to the overflow was the
concession.
**And a shared component brings its test handles with it.**
`bottom-nav`'s "More" opens the *existing* `<app-sidebar>` in a drawer —
the whole point being not to write a second list of destinations — but
rendering it unconditionally put a second `data-testid="nav-home"` (and
ten siblings) in the DOM. **30 existing specs failed** with "strict mode
violation: resolved to 2 elements", on a *desktop* viewport where
`bottom-nav` is `display: none` and the drawer can never open. Lazy
rendering fixes it; the component test asserts the absence, because the
failure is invisible from inside the component and appears in files
nobody touched.
Three smaller things worth keeping:
- **A new icon name is a runtime failure, not a build one.** `bars` was
not in `src/icons/names.txt`, so `offline-icons.spec.ts` caught it —
the sweep asserts `window.__yjIconMisses` is empty. `node
frontend/scripts/fetch-icons.mjs` re-vendors after adding a line.
- **A `wa-drawer` animates, so a test asserts its events**, not its
`open` property: setting `open = false` starts a hide that has not
finished on the next microtask, and a test reading the property in
between sees the state it is leaving.
- **`update(el)` in the component tier takes two arguments**
(`update(el, {})`), which is only visible from `tsc`, not from a
failing test.
### The local e2e tier was not running the same app CI runs
`requested-badge.spec.ts` failed two of three tests locally while CI was
green, and the reason is worth more than the fix: **`dev-headless.sh`
was the only place that did not neutralise `YJ_CORE_INDEX_URL`.**
`seed-sandbox.sh` and `ci.yml` both point it at `127.0.0.1:1`; the dev
launcher did not, so the app downloaded and built the real ~1M-row
Explore catalog into the run's `YJ_HOME`, and a local `make e2e` then
ran against a world CI never sees.
Found by reading the failure screenshot: the spec had searched Explore
for its fixture album and the page was full of *real* ones — Real
Estate, Arrested Youth, The Yes Album. The staged row was there and
invisible among a million others.
`dev-headless.sh` now defaults the variable to the dead address and
takes an explicit one if you want the real catalog for exploring by
hand. `make e2e` locally: 97 passed / 3 failed before, 100 passed
after.
The second half of the same problem is that **the backend is one shared
process with one database, and specs leave rows in it.**
`explore-shelves` staged its catalog only `IfEmpty`, so a single album
row left behind by `requested-badge` satisfied that gate, the shelves
were drawn from one foreign row, and the artist card the spec clicks did
not exist. It fails on the *second* local run and passes on the first,
which is the least useful order, and never in CI, where every run gets a
fresh `YJ_HOME`.
"Is the catalog empty" was the wrong question; "are my rows there" is
the right one. The staging is unconditional now (`INSERT OR IGNORE`
keyed on the MBID) and the assertion moved from *this insert wrote a
row* to *every fixture row is present* — which is both idempotent and a
stronger check, since an MBID failing `CHECK(length(mbid) = 16)` is
silently dropped by OR IGNORE and would otherwise show up as an empty
page rather than a failed setup.
**Verified: the full suite runs twice against the same app, 100 passed
both times.** That is the property to keep — a spec tier whose second
run differs from its first is a tier that will one day blame the wrong
commit.
## A media query adds no specificity, and dead CSS looks like working CSS (2026-08-16)
Plan 016 B2 phase 2 shipped the full-screen now-playing view, and
checking it with a screenshot found that **phase 1's shell rules had
never applied**.
`index.css` is base rules then component rules, and the phone block had
been inserted in the middle — above the plain `.top-bar` and `.title`
rules it meant to override. A media query is not a specificity boost,
so with equal specificity the *later* declaration wins. Measured at
390px before the fix:
| declared for the phone | actually computed |
|---|---|
| `padding-left: 0.75em` | 32px (the 2em base) |
| `gap: 0.5em` | 16px (base) |
| `font-size: 1.1em` | 24px (the 1.5em base) |
| `grid-template-columns: minmax(0,1fr) auto auto` | `320px 1fr auto` (base) |
After moving the block to the end of the file: 12px, 8px, 17.6px, and
`154px 187px 33px`.
**Nothing failed while they were dead**, which is the part worth
keeping. The phone spec asserts that the shell does not scroll
sideways, and it did not — because the fitting was being done by
`min-width: 0` and by each component's *own* media query, which live in
their own stylesheets and so had no later rule to lose to. The
declarations that did nothing were the cosmetic ones, and no assertion
was ever going to see them. A screenshot did, in about ten seconds.
The file now ends with one phone section, and says why it is last.
### What the same screenshot found about the view itself
The bottom bar was still rendering the mini player *underneath* the
full-screen view — 4em of a 844px phone spent saying exactly what the
view above it says, and invisible to every assertion about either one
(both were correct on their own). `index.css` hides `.bottom-bar` while
`#main-content[data-active-view="now-playing"]`, through `:has()`
rather than a class toggled from `index.ts`: which view is showing is
already published as an attribute, and a second expression of the same
fact is a second thing to keep in step.
That took the queue button away with it, since that button lives in the
bar — so the view carries its own, toggling the same `open` attribute
on the same panel element.
**And a css`` literal cannot contain a backtick.** A comment reading
"the track size is set on the `wa-slider` inside its shadow root"
terminates the tagged template, and the failure arrives as
`Expected "]" but found "wa"` from the CSS parser, at a line number in
the *comment*. `make css-check` exists for this and named it
immediately.
## The index artifact could not be exported, and the reason is a rule this repo already had (2026-08-16)
`maintain-index` failed on an unrelated push:
```
indexexport: copy rows: SQL logic error: no such column: total_tracks (1)
```
Three minutes in, on the one job that owns the ~205 GB checkpoint and
publishes the catalog every user downloads.
**The cause is the exception that keeps that checkpoint alive.** The
index job's `/cache` is a real `YJ_HOME` that survives between runs, so
`explore_index` there is classified `Cache` and is deliberately *not*
dropped and recreated by `cmd/indexbuild`'s schema repair
(`staleschema.go`). A column added to the schema afterwards is
therefore simply absent from that database — and `total_tracks` was
added by the album-completeness work. The exporter selected it anyway.
**The fix is the rule the importer already follows.**
`artifactHasTotals()` exists precisely because "adding a column to the
importer's SELECT is how you break every artifact already published";
the mirror image — *reading* an index older than the binary — had no
such guard. `sourceColumns()` asks
`pragma_table_info('explore_index', 'main')` and selects a literal `0`
when the column is not there, which is what the column already means by
"the catalog does not say" and what the app already renders as unknown
rather than as incomplete. The destination keeps every column, so an
importer needs no second shape.
So the pattern generalises, and is worth stating once: **any query that
crosses a version boundary in either direction asks the schema rather
than trusting it.** There are now three of these — `artifactStoresText`
(encoding), `artifactHasTotals` (import), `sourceColumns` (export).
Two things about the test are worth keeping.
It reproduces the failure **symptom first**: with the fix removed it
fails with the CI message verbatim, `copy rows: SQL logic error: no
such column: total_tracks (1)`. That was checked, not assumed.
And its first version silently proved nothing. `oldColumns` was
`strings.Replace(catalogColumns, "total_tracks, ", "", 1)` — which
matches *nothing*, because the list is formatted across lines and the
name is followed by a newline rather than a space. So the "old" index
had every current column, the probe correctly said so, and the only
reason this was caught is that the assertion about the probe ran before
the assertion about the export. A fixture built by string surgery on a
formatted constant needs to be whitespace-independent; it filters the
list now.
## Long-press is one document listener, and the header row is a row (2026-08-17)
Plan 016 B2 phase 3. A phone has no right-click, and every context menu
in this app opens from a `contextmenu` event — six components' worth,
bound three different ways (delegated on a virtualizer, per row, per
card). `frontend/src/utils/long-press.ts` is one document-capture
listener installed once from `index.ts`: a touch that holds still for
500 ms dispatches a synthetic `contextmenu` at the touch point, and
**every existing handler runs unchanged**. No component opted in, and
none can forget to.
Four things it has to get right, and each is a way the obvious version
fails:
- **The target is `composedPath()[0]`, not `elementFromPoint`**, which
stops at the outermost shadow host. Every menu here is bound inside
one, so a host-targeted event reaches a delegated listener and no
per-row one.
- **A browser that fires its own must win.** Chromium already dispatches
`contextmenu` on long-press; WebKit and the WebView vary. One arriving
during the press cancels ours; one arriving after ours is swallowed at
document capture.
- **Ours is told from theirs by identity** (a `WeakSet`), not by
`isTrusted`. `isTrusted` would work in the app and is untestable — no
test can dispatch a trusted event — so the suppression path would have
been the one thing with no coverage.
- **The click ending the gesture is swallowed**, keyed on the gesture
(cleared by the next `pointerdown`) rather than a time window, or a
quick tap on the menu that just opened is eaten too.
**What cost the time was the assertion, not the code.** The e2e spec
pressed `[role="row"]` — which is the *column header*, and it is the
first one. The gesture fired correctly, the header correctly ignored it,
and the failure looked exactly like a menu that would not open. Found by
probing the running app (`playwright-cli eval`, dispatching the same
pointer events and logging what saw the `contextmenu`), which showed the
event reaching the row's own listener with no menu behind it — i.e. the
handler was refusing it, not missing it. `.track-row` is the selector.
Verified by execution: 8 component tests (real browser, real shadow
boundary, real timings) and 2 e2e specs against the running app, twice
in a row. Not verified: any of it under a real finger on a real
WebView — the pointer events are dispatched, because neither Desktop
Chrome nor Desktop Safari has touch and there is no device tier.
## The first device run: A4 works, and two things only a phone could say (2026-08-17)
The published v1.5.0 APK, on a real phone, owner-reported. **This is the
first runtime evidence any of the Android work has ever had** — A4
shipped entirely reasoned from source.
**What holds.** Playback survives the screen locking. The MediaSession
notification appears in the status pane *with album art* — which
answers, in one observation, four of the open questions from plan 016:
the foreground service starts, POST_NOTIFICATIONS was granted and the
notification is visible, the session is picked up, and **cover art
decoded from a `MANAGE_EXTERNAL_STORAGE` path by a service is
readable**. The last was the one nobody could argue from documentation.
**Two bugs, and neither is visible from any tier we have.**
*Back did not navigate back.* The scaffold's
`MainActivity.onBackPressed` asks `webView.canGoBack()` and finishes the
activity otherwise — and this app had never touched `history`, so that
was false at every depth and back quit from anywhere. The fix is in the
frontend, not in Java: a navigation is a `history` entry now
(`recordNavigation` in `index.ts`, same URL, the destination in the
entry's state) and `popstate` replays it with `_isBack`. The Java half
needs no change, because the mechanism it already uses is the one we
were failing to feed.
Two rules keep it honest. The **first** navigation replaces the launch
entry rather than pushing one, or every launch costs a back press before
the app will close. And the in-app back buttons go through
`history.back()` rather than popping a stack of their own — `navStack`
is **deleted**, not kept alongside, because two stacks is exactly how
the detail view's own button and the phone's gesture come to disagree
about how far back one press goes. `back-navigation.spec.ts` pins that
invariant.
*The transport was off screen.* **`targetSdk 35` is Android 15, which
lays every app out edge-to-edge**, ignores the deprecated
`statusBarColor`/`navigationBarColor` the theme still sets, and hands
the app a window the size of the screen. The WebView is `match_parent`,
so the page's bottom band — the transport, and on a phone the tab bar —
was drawn underneath the gesture bar. `applyWindowInsets()` pads the
container by `systemBars | displayCutout | ime` and returns the insets
rather than consuming them. The window background goes black to match
the app's own ramp, or the padding shows as a blue-grey band.
**Neither is findable in the browser tier, and that is the lesson worth
keeping**: a viewport has no system bars, so `phone-shell.spec.ts` at
390x844 renders a shell that fits perfectly while the device cuts 48dp
off the bottom — and `page.goBack()` was never called because nothing in
a desktop shell has a back gesture. The Android tier's own note says
failure there is invisible; this is the milder version, where the app
works and is simply wrong in ways only the platform can show you.
Verified by execution: the APK builds with the Java change; 3 e2e specs
cover the history behaviour, on Chromium locally and WebKit in CI.
Not verified: the insets themselves, which need the next APK on the
owner's phone. What to look for is one thing — the transport and the tab
bar clear of the gesture bar, and the header clear of the status bar.
## The phone is a Chrome 113 WebView, and that reframes everything (2026-08-17)
The device is reachable over adb now, so the tier can be *asked* rather
than reported on. `make android-inspect` + `make android-eval` are that:
a debug build (`applicationIdSuffix ".dev"`, so it installs **beside**
the release app rather than needing the uninstall that would take the
library with it) opens `webview_devtools_remote_<pid>`, and raw CDP over
Node's built-in WebSocket evaluates in the real page. **Playwright
cannot do this** — `connectOverCDP` calls `Browser.setDownloadBehavior`
and a WebView answers "Browser context management is not supported",
killing the connection before the first evaluate.
Measured on the device (Light Phone III, TLP301):
| fact | value |
| --- | --- |
| Android | 14, SDK 34 |
| screen | 1080x1240, density 408 |
| WebView viewport | **424 x 439 CSS px**, DPR 2.55 |
| WebView engine | **Chrome 113.0.5672.136** (mid-2023) |
**The first correction: the insets commit does not explain the report.**
Edge-to-edge is forced for apps *running on* Android 15, and this phone
is Android 14 — the screenshot shows the app correctly inset, with the
status bar and the gesture bar outside it. `applyWindowInsets()` is
right and stays (the next phone, or one OS update, is Android 15), but
it is **pre-emptive, not the fix for "the controls are off screen"**.
That was an inference from a version number, and the device disagreed.
**The second correction: the black `fill` proves nothing.** A wa-icon on
the device has the right `color` (#ffd43b) and an `<svg>` in its shadow
root, and `getComputedStyle(svg).fill` is black — but that is the *svg
root*, and every vendored Font Awesome path carries
`fill="currentColor"` itself, so the root's fill is irrelevant. Measuring
the wrong node produced a diagnosis-shaped result. `__yjIconMisses` is
empty, so no name is unbundled either. Why the icons do not appear in the
screenshot is **still open**.
**What the engine version does explain, and what to check next.**
Chrome 113 has `:has()`, `color-mix()` and `dialog.showModal()`, and
lacks three things this app's dependencies use:
- **Relaxed CSS nesting** (Chrome 120): a nested rule starting with a
bare element selector is dropped. `.x { svg { ... } }` parses to
nothing; `.x { & svg { ... } }` parses. Any Web Awesome or app
stylesheet written the modern way silently loses declarations here,
and dropped declarations are exactly the failure that looks like
"rendered but wrong".
- **The Popover API** (Chrome 114). Web Awesome's popup calls
`showPopover?.()` — optional, so nothing throws — but also sets
`popover="manual"`, which on 113 is an unknown attribute doing
nothing. Every context menu, dropdown and the whole menu keyboard
model rides on that, so it is the first thing to test with a library
present.
- `light-dark()` and relative colour syntax (`rgb(from ...)`).
**The lesson for the tier: a device is an engine, not just a screen.**
Every browser tier here runs a current Chromium or WebKit, and the phone
that will actually run this app is two years behind — so "it renders at
424x439 in Chromium" (checked, the transport is on screen) says nothing
about whether it renders on the phone. The e2e tier cannot be fixed by
resizing; the missing signal is version, and CDP against the device is
the only place to get it.
Verified by execution: every number in the table, the four feature
probes, and that the hardware back button no longer kills the app (the
`.dev` build carries the history fix; pid survived a BACK press).
Unverified: what happened to the icons and the transport controls, which
is where this resumes.
@@ -0,0 +1,383 @@
# 015 — Android release pipeline
Ship an Android APK from CI on every version tag, published to the Gitea
generic package registry so Obtainium can poll a plain URL.
The baseline is `~/Development/ljos`, whose `.gitea/workflows/ci.yml`
`android:` job has been through the failure modes already. Most of what
follows is a transcription of that job onto this repo's conventions;
where it differs, the difference is argued.
## What this is not
**This ships a pipeline, not a usable Android music player.** The
success criterion is a signed, installable APK that launches — not an
app anyone would want. Explicitly out of scope, and each is real:
- `backend/mediacontrols/mpris_linux.go` **will be compiled on Android**.
Go's `android` GOOS implies the `linux` build tag, so the `//go:build
linux` file is in the build and MPRIS will look for a session bus that
does not exist. It compiles; it will error at runtime.
- `backend/system` resolves XDG paths. Android has no XDG.
- The explore catalog artifact is ~0.6 GB. Nothing on a phone wants that.
- The shell is a desktop shell: an eleven-item sidebar, a 800×600
measured minimum, a transport bar. None of that is a phone layout.
- The library scanner walks a filesystem Android does not grant.
Those are the *next* plan, if there is one. Conflating them with this one
is how a build pipeline takes six weeks.
## Phase 0 — the gate [DONE 2026-08-16]
**Passed, further than asked.** No source changes were needed; a full
27 MB fat APK built first try, both ABIs, production-stripped. Numbers,
the environment and four non-obvious findings are in
`.planning/NOTES.md` — including a scaffold bug that put a *debug*
library in the release APK's phone ABI, fixed here.
**It also installs and launches on an emulator, and then exits.** One
line stops it: `backend/system/buildUserDirPath` switches on
`runtime.GOOS` and Android takes the `default:` branch returning
`errUnsupportedOS`, so `main()` hits `os.Exit(1)` six milliseconds
after the JNI bridge comes up. That is the *first* thing that stops it,
not the only one — see the "not this" section above, all of which is
still true and still out of scope.
The emulator tier that found it is now part of the harness:
`scripts/android-emulator.sh`, the `make android-*` targets, and
`.pi/skills/yellowjacket-dev/references/android-tier.md`. It exists
because the failure is invisible in all three places anyone would look
(no panic, no tombstone, no crash buffer) and ActivityManager restarts
the app fast enough that `pidof` always answers — so the tier's
assertion is "same pid after N seconds", not "it started".
Original phase 0 text follows, kept because its reasoning is what the
later phases rest on.
Everything downstream is wasted if the c-shared link fails. Establish it
by hand, locally, before writing a line of YAML.
Already established, by probe rather than by assumption:
```
GOOS=android GOARCH=arm64 CGO_ENABLED=0 go build ./backend/... ./internal/...
```
compiles the entire tree. Exactly two packages fail, and both fail only
because their Android implementation is cgo:
- `ebitengine/oto/v3``driver_android.go` needs the bundled **oboe**
C++ backend. Oto supports Android natively; there is no Java audio
glue to write.
- `wails/v3/pkg/application``mobile_features_android.go` needs the
JNI bridge.
`modernc.org/sqlite` (the whole database layer), `beep`, `godbus` and
every `backend/` package are clean. **No source changes are known to be
required**, which is the single most surprising finding here and the
reason this plan is worth doing at all.
What Phase 0 must actually verify:
1. Install NDK **r26d** (`26.3.11579264`) locally. Pinned, not "whatever
sdkmanager gives you" — ljos's AGENTS.md records newer NDKs breaking
this build.
2. Generate the scaffolding (Phase 1) and run
`wails3 task android:compile:go:shared ARCH=arm64` by hand.
3. Confirm `build/android/app/src/main/jniLibs/arm64-v8a/libwails.so`
exists and is an ARM64 shared object.
4. Repeat for `amd64` (the emulator ABI).
**If the link fails, stop and re-plan.** The likely culprits, in order:
alsa (oto must select oboe, not ALSA — if it reaches for `alsa.pc` the
build tags are wrong), and `main.go`'s `//go:embed all:frontend/dist`
combined with the generated `main_android.gen.go` overlay.
Deliverable: a note in `.planning/NOTES.md` recording the exact command
and the NDK version that produced a `.so`, or the reason it cannot.
## Phase 1 — un-ignore and commit the Android scaffolding [DONE]
Done as a side-effect of phase 0, which could not run without it. One
correction to the text below: **step 1 is wrong.** `update
build-assets` does not generate the android tree (NOTES.md explains);
it was generated with `generate build-assets` into a scratch dir and
`android/` copied across. CLAUDE.md is corrected to match. Steps 2-5
were done as written.
`build/android/` is gitignored (`.gitignore:72`) and its `includes:`
entry was dropped from `Taskfile.yml` during plan 009. That was correct
when nothing could target Android and is what has to be undone.
1. `wails3 task common:update:build-assets` — beta.8 embeds
`internal/commands/build_assets/android/`, so this generates the tree.
2. Remove `build/android/` from `.gitignore`; add `build/ios/`'s reason
to a comment so the asymmetry is explained rather than looking like an
oversight.
3. Add `android: ./build/android/Taskfile.yml` to `Taskfile.yml`'s
`includes:`.
4. **Gitignore the tree's own output**, or the repo grows a few hundred
Gradle intermediates. ljos has exactly this problem — its
`app/build/android/app/build/**` is committed. Ignore:
- `build/android/app/build/`
- `build/android/app/src/main/jniLibs/`
- `build/android/overlay.json` and `build/android/gen/`
5. `make build-prod` and `make test` still pass — the new include must
not perturb the desktop path.
**The refresh hazard has to be written down.** CLAUDE.md's Packaging
section already says `build/`'s platform metadata is regenerated from
`build/config.yml` and hand edits are lost. Phase 2 edits `build.gradle`
by hand. Extend that paragraph to name `build/android/app/build.gradle`
specifically, because the loss is silent and the symptom (a debug-signed
APK) appears months later as a failed update.
## Phase 2 — make the APK identifiable and updatable [DONE 2026-08-16]
**Narrower than planned, because beta.8's scaffold is ahead of ljos's
beta.3: the release signing config already exists** and reads the four
`ANDROID_KEYSTORE_*` variables with a debug-keystore fallback. So this
phase was identity and versioning only. Verified end to end:
| | |
|---|---|
| package | `app.yellowjacket` (was `com.wails.app`) |
| versionCode / versionName | `10301` / `1.3.1`, from `YJ_VERSION_CODE` / `YJ_VERSION` |
| label | `YellowJacket` |
| signing | throwaway keystore -> `Signer #1 DN: CN=YellowJacket Test`, not the debug key |
| ABIs | arm64-v8a + x86_64, both production-stripped |
Installs and launches under the new identity. Still exits on the known
`buildUserDirPath` bug, which is phase 0's finding and not this phase's.
Two things this phase learned that the text below did not know:
- **The identity has to be declared twice.** `applicationId` in
`app/build.gradle` is what Gradle installs; `APP_ID` in
`build/android/Taskfile.yml` is what every adb-driven task targets.
`ANDROID.md` says to set `APP_ID` in `build/config.yml` — that does
nothing in beta.8, verified with `--dry`. Both are set, each
commented pointing at the other.
- **The launcher activity is not under the applicationId.** It stays
`com.wails.app.MainActivity` (the scaffold's Java package), so
`am start -n app.yellowjacket/.MainActivity` resolves the dot against
the wrong package and fails. `scripts/android-emulator.sh` carries the
fully-qualified name and a comment saying why.
The `keytool` PKCS12 note below was confirmed verbatim: given a
`-keypass` differing from `-storepass` it prints "Different store and
key passwords not supported for PKCS12 KeyStores. Ignoring
user-specified -keypass value."
Original phase 2 text follows.
Edit `build/android/app/build.gradle`, following ljos's, whose comments
are worth reading before writing this:
- `applicationId "app.yellowjacket"` — matches `config.yml`'s
`productIdentifier`. The `namespace` stays `com.wails.app` (it is the
Java package, not the app identity).
- `versionCode Integer.parseInt(System.getenv("YJ_VERSION_CODE") ?: "1")`
**`Integer.parseInt`, not `(...) as Integer`**. Groovy binds the
parentheses to `versionCode` first, so the cast reads as
`versionCode("1") as Integer`, which sets a String and then casts the
setter's null return; Gradle fails the whole project with "Value is
null" at that line.
- `versionName System.getenv("YJ_VERSION") ?: "0.0.0"`.
- `abiFilters 'arm64-v8a', 'x86_64'`.
- A `release` signing config reading `ANDROID_KEYSTORE_FILE` /
`_PASSWORD` / `ANDROID_KEY_ALIAS` / `ANDROID_KEY_PASSWORD`, falling
back to the debug keystore only when no keystore is supplied.
**Android orders releases by an integer and refuses anything not greater
than what is installed.** A hardcoded `versionCode 1` means the first
install is the last: every later build is rejected as a downgrade and the
only fix is an uninstall. `1.3.1 -> 10301`, monotonic as long as minor
and patch stay under 100.
**Signing is not optional past the first install.** Android refuses to
update an app whose signing key changed, and the debug keystore differs
between every machine and every runner — so an unsigned CI build is a
decision to reinstall by hand forever. The job must **refuse to build**
without the keystore rather than quietly produce an APK that can never be
updated.
There is **one password and two required secrets**. keytool has defaulted
to PKCS12 since JDK 9 regardless of the `.jks` extension, and PKCS12
cannot hold a separate key password — given `-keypass` it warns and
ignores it. So `ANDROID_KEY_PASSWORD` defaults to the store password and
`ANDROID_KEY_ALIAS` to `yellowjacket`. Asking for a second password that
cannot exist is how someone sets a wrong value and debugs Gradle at
midnight.
Add `make android``PATH="$(TOOLBIN):$$PATH" go tool wails3 task
android:package:fat`, beside `build-prod`. `make skill-check` fails on a
documented target that does not exist, so document it only once it does.
## Phase 3 — the workflow [DONE 2026-08-16]
`.gitea/workflows/android-apk.yml`, plus `docs/android-release.md` as
the operating document its error messages point at (phase 4's
documentation half; the secrets themselves still have to be created by
hand — see the table there).
Three departures from the text below, all argued in the file:
- **No `continue-on-error`.** The plan inherited it from ljos, where
the Android job shares a pipeline with a server deploy that must
never go red over a phone build. Here it is standalone and can
neither delay nor redden anything, so a release step that fails
silently is strictly worse than one that fails visibly.
- **No cached `wails3` binary.** The plan budgeted for ljos's
`tools-bin` copy. Unnecessary: the CLI is a vendored `go tool`, and
the runner already bind-mounts `GOCACHE`/`GOMODCACHE` for every job,
so it is warm from `ci.yml`'s own `make bindings-check`. The GTK and
WebKit *dev* headers are still installed, because `go tool wails3`
links them.
- **A fourth cache volume, `/cache/gradle`.** Not in the plan and worth
~700 MB a run.
Four publish-gates were added and each was checked against a real APK:
both ABIs present, `versionCode` equal to the one derived from the tag,
a non-empty artifact, and **not signed with the debug key** — verified
by pointing the check at a deliberately debug-signed build, which it
refused.
Rehearsed locally with the exact CI invocation
(`make android ANDROID_SDK=... ANDROID_NDK=...`, `YJ_VERSION`,
`YJ_VERSION_CODE`, a throwaway keystore): `app.yellowjacket`,
versionCode 10301, versionName 1.3.1, label YellowJacket, both ABIs,
`Signer #1 DN: CN=YellowJacket`. Not yet run on the runner.
Original phase 3 text follows.
New file: `.gitea/workflows/android-apk.yml`. **Not a job in `ci.yml`.**
`ci.yml` runs on every branch push and is the workflow that gates; the
runner is capacity 1, and a 45-minute Android build in it would put every
push behind an SDK download.
```yaml
on:
push:
tags: ["v*"]
workflow_dispatch:
```
This is where the baseline genuinely diverges. ljos computes its version
in CI (`scripts/next-version.sh`) and gates the Android job on
`needs.release.outputs.version != ''`, with an `always()` whose absence
would silently kill the manual path. **This repo has no release
automation** — tags are pushed by hand and `homebrew-formula.yml` already
keys on `v*`. So there is no `needs:`, no `always()`, and no status
function to get wrong: the tag *is* the version, and a dispatch falls
back to `git describe --tags --abbrev=0`.
Container, matching `ci.yml`'s conventions (`ubuntu:24.04`, clone by hand
with `PACKAGE_TOKEN` rather than `actions/checkout`, which is a JS action
needing node before any step has installed it):
```yaml
container:
image: ubuntu:24.04
volumes:
- /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool
- /home/logan/docker/gitea/data/runner/cache/android-sdk:/cache/android-sdk
```
The SDK path must be inside the runner's `valid_volumes` allowlist —
a directory outside it makes the job **fail to start**, not silently skip
the mount. `/cache/tool` is already allowed and already holds the Go
toolchain `ci.yml` downloads.
`continue-on-error: true` and `timeout-minutes: 45`. Advisory, because a
tag's other three workflows must not go red over a phone build, and a
backstop because a wedged SDK download must not hold the only runner slot
for hours.
Steps:
1. **System packages.** `ci.yml`'s set plus `unzip` and `openjdk-17-jdk`.
`libasound2-dev` stays — it is for the *host* `wails3` build, not the
Android cross-build, which uses oboe.
2. **Go toolchain** — reuse `ci.yml`'s `/cache/tool/go` block verbatim.
3. **Android SDK and NDK (cached).** ljos's `install_if_missing`
idempotent guard, unchanged: cmdline-tools 11076708, `platform-tools`,
`platforms;android-34`, `build-tools;34.0.0`, `ndk;26.3.11579264`.
sdkmanager is itself idempotent but still spends minutes verifying,
which is why the explicit directory guards are there. ~3 GB and most of
the job's wall clock on the first run; a directory listing after.
4. **wails3.** Cheaper here than in ljos, which pins
`go install …/wails3@$version` against `app/go.mod`. This repo vendors
the CLI (`go tool wails3`, `scripts/toolbin/wails3`), so the version is
already pinned by `go.mod` and there is nothing to drift. It still
*links* GTK and WebKit, so cache the built binary in
`/cache/android-sdk/tools-bin` keyed on the wails version — and note
ljos's finding that **caching the binary alone turned a slow job into
a broken one**: `wails3` is dynamically linked, so the runtime
packages are needed even on a cache hit. Here they are already in
step 1.
5. **Frontend + codegen.** `pnpm install --frozen-lockfile && pnpm build`
(pnpm, not ljos's npm), then `make generate`. `main.go` embeds
`frontend/dist`, so nothing Go-side typechecks without it.
6. **Decode the keystore.** Refuse to build if `ANDROID_KEYSTORE_B64` is
unset, with the sentence explaining why (Phase 2). Decide the absolute
path *here* and export it via `$GITHUB_ENV`**`${{ env.HOME }}`
evaluates to an empty string in Gitea's expression context**, which
turned `$HOME/x.jks` into `/x.jks` and surfaced as a missing file
fifty-five seconds into a Gradle run.
7. **Build.** Compute `YJ_VERSION_CODE` from the tag, verify the keystore
opens with `keytool -list` *before* Gradle does (Gradle only notices at
`:app:validateSigningRelease`, a minute in, and reports it as a missing
file), then `make android`.
8. **Verify the signature.** `apksigner verify --print-certs`, and print
the SHA-256 with the note that a change to it breaks every future
update. **Nothing here pipes into `head`**: under `set -o pipefail`,
`head -1` exits early, the producer takes SIGPIPE, and the step fails
with 141 *after* printing a perfectly good APK. Use `find … -print
-quit` and a captured variable.
9. **Publish** to `api/packages/${OWNER}/generic/yellowjacket-android`,
authenticating `--user "${OWNER}:${PACKAGE_TOKEN}"` — the same
credential pair `arch-package.yml` already uses, not ljos's
`REGISTRY_USER`/`REGISTRY_TOKEN`. Two copies: a versioned one for
history and a fixed `latest/yellowjacket.apk` that Obtainium watches.
Gitea refuses to overwrite, so delete `latest` first. The generic
registry is readable **without credentials**, which is what lets
Obtainium poll a plain URL with no token and no public source mirror.
## Phase 4 — secrets and documentation
Secrets to create on the repo (all under Settings → Actions → Secrets):
| Secret | Required | Note |
|---|---|---|
| `ANDROID_KEYSTORE_B64` | yes | `base64 -w0 yellowjacket-release.jks` |
| `ANDROID_KEYSTORE_PASSWORD` | yes | |
| `ANDROID_KEY_ALIAS` | no | defaults to `yellowjacket` |
| `ANDROID_KEY_PASSWORD` | no | defaults to the store password |
| `PACKAGE_TOKEN` | already exists | used by `arch-package.yml` |
Write the keytool command, the Obtainium URL and the signing-key warning
into a docs page — this is the part of ljos's setup that lives in
`docs/clients.md` and is referenced from the workflow's error messages,
so the messages have somewhere to point.
Then extend CLAUDE.md's CI section: it currently says "four workflows,
three of them package and publish; only `ci.yml` gates". That becomes
five, with the same sentence still true.
## Order and stopping points
Phase 0 gates everything. Phases 12 are one commit's worth of work and
are verifiable locally without CI. Phase 3 is the only part that needs a
runner, and its first run will be slow and will probably fail once on
something in the SDK step — budget for that rather than treating it as a
setback.
**Stop after Phase 0 if the c-shared link does not work.** Every later
phase is scaffolding for a build that does not exist, and the honest
outcome is a NOTES.md entry saying which package cannot cross-compile and
what it would take.
@@ -0,0 +1,389 @@
# 016 — What Android parity would actually take
> **Status: all of section A is done.** A1A3 landed with "let the app
> reach the user's music"; A4 (MediaSession, transport notification,
> audio focus) landed with "survive the screen locking". The direction
> taken is **option 1, the full librarian**: `MANAGE_EXTERNAL_STORAGE`
> plus an in-app folder browser, which keeps the path-keyed model
> intact. B1/B2 remain, both awaiting a decision rather than work. The
> sections below are kept as written, because they are the argument the
> decision rests on — see "What is left" at the end for the current
> state.
Plan 015 shipped a *pipeline*: the app cross-compiles, is signed and
versioned, and publishes from CI. This is the assessment of what stands
between that and an Android app worth installing.
**The headline: parity is the wrong target, and choosing it would be
the expensive mistake.** Four of the blockers below are not porting work
— they are the Android platform declining to support the model this app
is built on. The decision to make first is in "The fork in the road" at
the end; everything before it is evidence for that decision.
Severity is what the app *does* today, verified against the source and
the generated manifest, not guessed.
## A. It cannot work at all until these are fixed
### A1. The app can read no music. (deepest)
`build/android/app/src/main/AndroidManifest.xml` requests INTERNET,
VIBRATE, ACCESS_NETWORK_STATE, USE_BIOMETRIC, POST_NOTIFICATIONS, the
two location permissions, CAMERA and the two FOREGROUND_SERVICE ones.
**There is no storage or media permission of any kind.** At
`targetSdk 35` that means the app can see its own private directory and
nothing else.
Adding `READ_MEDIA_AUDIO` is necessary and *not sufficient*, because it
grants access through **MediaStore**, not through the filesystem. This
app's entire model is absolute paths: `audio_files.file_path` is the
primary key of ownership, `AddLibrary(path)` takes a directory, the
scanner walks it with `os.ReadDir`, and every one of
`GetFilePathsByAlbums` / `ByGenres` / `ByRecordingMBIDs` exists to hand
paths to the player. Scoped storage does not offer a stable directory
to walk.
The honest options are three, and they are not close in cost:
- **MediaStore as the library source.** Query the content resolver,
keep MediaStore IDs (or content URIs) beside or instead of paths, and
open audio through a `ContentResolver` file descriptor. This is the
Android-native answer and it touches the schema, the scanner, the
player's file opening and every path-keyed query.
- **`MANAGE_EXTERNAL_STORAGE`.** Keeps the path model intact and is
effectively barred from Google Play except for genuine file managers.
Viable *only* because we distribute through Obtainium — which is a
real point in its favour here, and worth stating plainly rather than
dismissing.
- **App-private storage only**, i.e. the user copies music into the
app's sandbox. Trivial to build, and nobody wants it.
### A2. The first-run flow cannot complete.
`first-run-wizard.ts` calls `DirectoryPicker()`, which is
`frontendutil.DirectoryPicker``app.Dialog.OpenFile().
CanChooseDirectories(true)`. Wails' own `ANDROID.md` lists open-directory
dialogs as **"❌ Returns an error — SAF yields tree URIs, not filesystem
paths"**. So the one action the wizard exists to perform fails, and
`<first-run-wizard>` intercepts all pointer events until a library
exists — so the app is not merely empty, it is inert.
Whatever A1 resolves to decides this: a MediaStore library needs no
picker at all, and a SAF tree needs the picker to return a URI the
backend can use.
### A3. MPRIS is compiled into the Android build.
`mpris_linux.go` is `//go:build linux`, and **`android` implies
`linux`** (documented, and the reason it is in the APK). It will look
for a session bus that does not exist. It needs `//go:build linux &&
!android`, and its Android counterpart is A4.
This one is cheap and should be done regardless — it is a two-character
build-tag change plus whatever `mediacontrols.New` returns instead.
### A4. Playback will be killed the moment the screen locks.
The scaffold's `WailsForegroundService` is typed **`dataSync`**
(`foregroundServiceType="dataSync"`, `FOREGROUND_SERVICE_TYPE_DATA_SYNC`),
and the manifest requests `FOREGROUND_SERVICE_DATA_SYNC`. A music player
needs `mediaPlayback` and `FOREGROUND_SERVICE_MEDIA_PLAYBACK`, plus a
`MediaSession` for lock-screen and notification transport controls,
plus **audio focus** — pause on a phone call, duck for a notification,
pause on headphone unplug. None of that exists today. `oto` will happily
keep writing to a stream nobody can hear.
This is the difference between "an app that plays audio" and "a music
player", and it is Java-side work in the scaffold plus a Go-side bridge.
## B. It works, but wrongly
### B1. The x86_64 half of the APK cannot run on any Android.
Established in plan 015: `modernc.org/libc`'s `Xlstat64` issues a raw
`lstat` on linux/amd64, which Android's seccomp forbids, so the process
takes `SIGSYS` the first time it touches the database. arm64 is
structurally unaffected (no `lstat` syscall exists; it routes through
`fstatat`).
So ~31 MB of the artifact is dead weight on *every* Android device,
including x86 Chromebooks. Options: drop `x86_64` from `abiFilters`
(smaller APK, no emulator target — which does not work anyway), or
carry it against a future modernc fix. **Dropping it is the honest
default**; it is also the only item in this plan that is a five-minute
change.
### B2. The UI is a desktop shell.
`MinWidth`/`MinHeight` are 800×600 and were *measured* — below ~780 the
header subtitle wraps the title out of its bar. A phone is ~360430 CSS
px wide. The sidebar collapses to icons below 900px, which is a
laptop-sized breakpoint, not a phone one. Beyond width: the app is built
on hover (the marquee's `hover` mode, tooltips), right-click context
menus, a keyboard shortcut layer with its own overlay and settings page,
multi-select with ctrl/shift, and a resizable-column track list. None of
those are gestures.
This is not a stylesheet pass. It is a second front end for the views
worth having on a phone, sharing the stores and bindings — which the
architecture supports, since a view is already a lazily-loaded chunk
behind `VIEW_LOADERS`.
### B3. Tag writing cannot reach the user's files.
`tagwriter` rewrites tags in place, and autotag's whole purpose is
applying them to a folder. Under scoped storage that is impossible
outside the sandbox without a SAF write grant per tree. If A1 lands on
MediaStore, in-place tag writing needs `MediaStore` write requests and
user confirmation per file on Android 11+.
Autotagging is arguably a desktop-only feature and saying so is a
legitimate answer.
### B4. The Explore catalog is a ~0.6 GB download into app-private storage.
It works — but with no awareness of a metered connection and no
accounting for a device where that is a meaningful fraction of free
space. At minimum it needs to be opt-in on mobile and to refuse a
metered network by default. `Android.NetworkJSON()` reports
`{connected,type}`, so the signal is available.
## C. Inert, and fine
Window geometry, menus and the system tray are documented no-ops on
mobile. The keyboard shortcut layer is harmless but its Settings page
is dead weight. `profiling` is already compiled out of production
builds. These cost nothing and need no work.
## D. Unknown until it runs on a device
**Nothing in section A or B has been observed on Android**, because the
x86_64 emulator cannot run the app (B1) and emulator 37 refuses arm64
images on an x86_64 host. Everything above is read from the source, the
generated manifest and Wails' own documentation. The first real device
run will find things this list does not have, and the most likely
places are audio latency and buffering under `oto`/oboe, and SQLite
behaviour on app-private storage.
## The fork in the road
The four blockers in section A are all the same question wearing
different clothes: **is the Android app a librarian, or a player?**
YellowJacket on the desktop is a *librarian*. It scans folders,
deduplicates covers, detects duplicate tracks, reconciles against
MusicBrainz, rewrites tags on disk, and manages downloads. That model
rests on owning a filesystem, which is precisely what Android declines
to give.
Three coherent products, and only the first is "parity":
1. **Full librarian on Android.** Requires `MANAGE_EXTERNAL_STORAGE`
(Obtainium-only distribution, which we already have), a phone UI for
every view, and media-session playback. Largest scope by far; the
result is an app almost nobody has asked for on a phone.
2. **A player for music already on the phone.** MediaStore as the
source, no scanner, no autotag, no downloads; the library, queue,
playlists, favourites and Explore-as-browsing all still make sense.
This is a genuinely good Android app and it is *not* parity — it is
a subset with a different data source.
3. **A companion to the desktop app.** The phone browses and controls
the desktop's library over the network, or syncs a subset. Smallest
Android surface, and it leans on the thing that already works.
**Option 2 is the recommendation** if the goal is an app people use;
option 3 if the goal is the least work for the most value. Option 1 is
the only one that answers "feature parity" literally, and it is the one
worth arguing hardest against.
> **Decided:** option 1's *data model* (the librarian keeps its
> filesystem and its scanner — A1 shipped that) with option 2's
> *surface*. The phone is a player over the library this app already
> builds; it does not get every view. The list is below.
## The phone gets a subset (decided)
B2 is not a stylesheet pass and not a second front end either. A view
is already a lazily-loaded chunk behind `VIEW_LOADERS` /
`DETAIL_LOADERS` in `index.ts`, and the stores and bindings are shared,
so the phone build is **a different loader table and a different
chrome**, over the same stores.
**In**, because each is something a person does with a phone in their
hand:
- **Home** — the shelves are already a phone-shaped surface.
- **Library browse** — albums, artists, genres. The grids are already
virtualized and card-shaped.
- **Now playing** — which on a phone is a *view*, not a 4em bar.
- **The queue.**
- **Search** — the header box, scoped as it already is.
- **Playlists**, including smart ones, as lists to play rather than to
edit.
**Out**, and each for a reason rather than by omission:
- **Autotag** — the review UI is a wide table and the action rewrites
files on disk; B3 has not been verified even as *possible* yet.
- **Downloads** — two tab panels of client configuration.
- **Explore** — the catalog is a ~0.6 GB download (B4); browsing it is
the last thing to earn a phone's storage.
- **Settings** — not the page. The phone needs a handful of settings
(theme, the library folder, playback) and not the 93 controls the
desktop page carries.
- **Jobs**, **shortcuts overlay**, **column configuration** — a phone
has no keyboard and no resizable columns, and the jobs indicator is
enough.
What the shell has to lose, from the audit at the top of this section:
the 800×600 minimum, the 11-item sidebar (a phone wants a bottom tab
bar over the five things above), hover as a route to anything,
right-click as the only route to a context menu (long-press is the
gesture), and ctrl/shift multi-select.
One rule for the work: **no view forks.** A phone layout that copies a
view's template is two templates to fix every bug in. Where a view
cannot serve both, the split belongs at the chunk boundary that already
exists.
Phase 1 followed that rule and found its cost: reusing `<app-sidebar>`
inside the drawer means reusing its `data-testid`s too, and a second
copy standing by in the DOM broke 30 specs that had nothing to do with
the phone. The rule holds — a second list of destinations would be
worse — but a shared component must be rendered only when it is wanted,
and the guard belongs in a test that names the reason.
## What is worth doing regardless of that decision
Cheap, independently useful, and each unblocks measurement:
1. **Drop `x86_64` from `abiFilters`** (B1) — or keep it and document
why. Five minutes.
2. **`//go:build linux && !android` on `mpris_linux.go`** (A3), so the
Android build stops carrying a D-Bus client. Small.
3. **A device smoke run**, which needs someone's phone and the published
APK. Everything in D depends on it, and it is the single highest
information-per-minute action available.
4. **Make the first-run wizard fail legibly** rather than inertly (A2)
— the picker's error already routes through `describeError`, but the
wizard still blocks pointer events, so an Android user sees a dead
screen rather than a sentence. Even under option 3 this is the right
behaviour.
## What is left (updated after A4)
**A4 is done.** `backend/mediacontrols/android.go` is a `Handler`
beside the MPRIS one, and the Java half is
`WailsForegroundService.java`: a `MediaSession`, a `MediaStyle`
transport notification and audio focus. It needed no new JNI and no new
Gradle dependency — `application.Android.StartForegroundService(json)`
going out, `WailsBridge.emitEvent` → the application event bus coming
back, and the platform `android.media.session` API rather than
androidx.media, which minSdk 21 makes available anyway.
Four decisions in it are worth keeping:
- **Ducking is a player concept, not a volume change.**
`Player.SetDuck` re-applies the *user's* level with an attenuation
offset, so `getUserVolume` still reports what the user chose and
nothing is persisted or emitted. A duck that wrote through to the
volume would let one notification tone permanently turn the music
down.
- **The duck path is pre-Oreo only.** From API 26 the framework ducks
the app itself and sends no `CAN_DUCK` focus change, so asking to be
told instead (`setWillPauseWhenDucked`) would mean pausing for every
notification tone, and doing both would attenuate twice.
- **An unchanged payload is not an event here either.** Every push
crosses JNI and re-delivers an Intent, and the player pushes state on
several paths that can agree.
- **After the first start, updates use `startService`.** From Android
12 an app in the background may not *start* a foreground service, but
it may keep delivering intents to one it already has — which is every
track change with the screen off.
The contract with Java — the payload keys, the state words, the command
names — is in `androidpayload.go`, deliberately *without* the `android`
build tag, so `go test` exercises it on every platform. Everything left
in `android.go` is untested by construction: it compiles only under a
cross-compiler and runs only on a phone.
**B1 is done: x86_64 is dropped.** 27.1 MB → 15.9 MB, measured. Three
places had to agree — `abiFilters`, the Makefile's `android:package`
(or Go still compiles a library Gradle then discards) and the
`native-code: 'arm64-v8a'$` assertion in `android-apk.yml`, whose
anchor is what stops it also matching the fat APK's line. Adding the
ABI back, if modernc ever fixes `Xlstat64`, is those same three edits.
**B2, the desktop shell.** Scope decided (below); **phases 1, 2 and 3
are done.**
- *Phase 1, the shell.* Below 600px the sidebar column is gone,
`<bottom-nav>` is the primary navigation, and the shell fits 320px
exactly — measured, from 652px in a 360px viewport before.
- *Phase 2, the full-screen now-playing view.* Where phase 1's seek bar
and volume went. A detail view, so Back pops the nav stack; it
composes the real transport components rather than copying them; and
it hides the bottom bar while it is up, so it carries its own queue
button.
- *Phase 3, long-press.* `utils/long-press.ts`: one document-capture
listener, installed once from `index.ts`, which turns a 500 ms
stationary touch into a synthetic `contextmenu` at the touch point.
Every menu in the app opens from that event, so all six components
gained the gesture without one of them changing — which is the same
argument `ContextMenuController` rests on, one layer lower. The
details that are not obvious are in `NOTES.md` (2026-08-17); the one
worth repeating is that ours is told from the browser's own
long-press event by **identity**, not `isTrusted`, because a test
cannot dispatch a trusted event and that path would otherwise be the
only uncovered one.
What is left of B2 is the track list, whose resizable columns are a
pointer feature with no touch equivalent. Not started.
**B3/B4** are unchanged, and B3 is now *possible* where it was not:
with all-files access, `tagwriter` can write in place.
### What the first device run answered (2026-08-17)
A4 **works**: playback survives the screen locking, and the transport
notification appears with cover art — which also settles the service's
access to a `MANAGE_EXTERNAL_STORAGE` path, the permission grant and
the lock-screen session in one observation. Everything below in "what
none of section A answered" was written before this and is now answered
except the OEM permission-flow variance.
It also found two faults no browser tier can see, both fixed and both
awaiting the next APK for confirmation (`NOTES.md`, same date):
- **Back quit the app from any depth.** The scaffold asks
`webView.canGoBack()`; the frontend had never used `history`. A
navigation is a history entry now, and `navStack` is gone rather than
kept beside it.
- **The transport was under the gesture bar** — or so the version
number said. `applyWindowInsets()` in `MainActivity` is right and
stays, but the phone is **Android 14**, where the system still insets
the window: the fix is pre-emptive and the symptom has another cause.
Still open, along with icons that do not appear at all. The phone's
WebView is **Chrome 113**, which is the lead (no Popover API, no
relaxed CSS nesting), and `make android-inspect` / `android-eval` are
how it gets asked.
The standing item is unchanged in kind: **B3 (tag writing) and the
permission flow still need a device**, and so does confirming these two.
### What none of section A answered
Nothing here has been observed on a device. The permission flow in
particular is the kind of thing that behaves differently across OEM
builds — `ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION` is
implemented inconsistently, which is why there is a fallback to the
global list, and neither path has been exercised.
A4 adds its own list of things only a device can answer, and they are
the likely first failures: whether the notification appears at all
(POST_NOTIFICATIONS is requested from `startForegroundService`, so a
user who declines gets a service with an invisible notification),
whether audio focus arrives while `oto`/oboe holds the output, whether
the lock screen picks up the session, and whether cover art decoded
from a `MANAGE_EXTERNAL_STORAGE` path is readable by the service.
+175 -11
View File
@@ -388,7 +388,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
@@ -709,6 +728,27 @@ moment it is most needed is the likeliest moment loading one fails.
`first-run-wizard` and the startup chrome are eager for the ordinary
reason — they are the first paint.
**A navigation is a history entry, and that is the whole back stack.**
`index.ts` records each navigation with `pushState` (same URL — the app
has no routes, and a path a reload cannot resolve is worse than none)
and replays `popstate` with `_isBack`. It exists for Android, whose back
button is not a key the page can bind: the scaffold's
`MainActivity.onBackPressed` asks `webView.canGoBack()` and finishes the
activity otherwise, so an app that never touched `history` quit from any
depth — which is what a device reported. Hooking the platform's own
mechanism rather than adding a JNI callback is also what makes it
testable in a browser (`page.goBack()`), and the Java half needed no
change at all.
Two rules hold it up. The **first** navigation *replaces* the launch
entry rather than pushing one, or every launch costs a back press before
the app will close. And the in-app back buttons (`navigate-back`, fired
by the detail views and `now-playing-view`) go through `history.back()`
rather than a stack of their own: the old `navStack` is **deleted**, not
kept beside it, because two stacks is precisely how a view's own back
button and the phone's gesture come to disagree about what one press
means.
**A primary view is cached, not unmounted.** `index.ts` keeps every
primary view in the DOM and toggles a `.view-hidden` class, because that
is what preserves `scrollTop` across navigation — so
@@ -864,6 +904,20 @@ against the real components:
moving focus without setting it leaves the highlight on whichever
item the mouse last touched.
**And a menu opens from a finger, through the event it already has.**
`utils/long-press.ts` is one document-capture listener installed once
from `index.ts`: a touch that holds still for 500 ms dispatches a
synthetic `contextmenu` at the touch point, so all six components that
bind one — delegated on a virtualizer, per row, per card — gained the
gesture without changing. The target is `composedPath()[0]` rather than
`elementFromPoint`, which stops at the outermost shadow host and so
reaches a delegated listener and no per-row one; a browser that fires
its own long-press `contextmenu` (Chromium does, WebKit and the WebView
vary) wins, ours being told from theirs by **identity** rather than
`isTrusted`, since no test can dispatch a trusted event; and the click
that ends the gesture is swallowed, keyed on the gesture rather than on
a time window so the first tap on the menu it opened is not eaten too.
Three lists had no focused row to open a menu *from* — the queue panel
and both playlist detail views — and gained a roving tab stop through
`utils/roving-rows.ts`. **`track-list` deliberately does not use it**:
@@ -1034,6 +1088,65 @@ this app promises, no scrollbar appears. Note that `overflow: hidden`
still permits *programmatic* scrolling, so a probe that sets
`scrollLeft` passes on the broken build; the spec uses a wheel gesture.
**Below 600px it reflows instead, and that is the phone.** The sideways
scroll above was the concession available while the shell had one
layout; plan 016 B2 gives it a second. Under 600px the grid drops its
sidebar column, `<bottom-nav>` takes over as the primary navigation,
the header's controls shrink or stand down, and the shell measures
exactly 320px in a 320px viewport — so `layout-overflow.spec.ts` now
asserts *nothing needs scrolling to*, which is what WCAG 1.4.10 wanted
all along. 600 rather than the sidebar's 900 because 900 is a laptop:
the answer there is a narrower sidebar, which is still a sidebar.
Three rules in it are load-bearing, and the second cost 30 specs.
**A grid item's implicit minimum is its content**, so one child that
insists on 580px makes the *body* 580px wide inside a 360px viewport
and `overflow-x: hidden` then hides a third of the app rather than
fitting it. Every box between the viewport and the content that must
shrink carries `min-width: 0`, and the things that cannot shrink say so
in their own stylesheet — `search-bar`'s 200px floor, `job-indicator`'s
label, `audio-player`'s seek bar and volume. A media query inside a
shadow root is answered by the viewport, so a component states what it
drops at phone width itself rather than the shell reaching in.
**A duplicated component duplicates its handles.** `bottom-nav`'s
"More" opens the *same* `<app-sidebar>` in a `wa-drawer` rather than
listing the destinations again — but rendering it unconditionally put a
second copy of every `data-testid="nav-*"` in the DOM, and 30 existing
specs failed with "strict mode violation: resolved to 2 elements" on a
desktop viewport where the element is not even visible. It renders only
while the drawer is open, and `bottom-nav.test.ts` asserts its absence
before that.
**The tab bar is four destinations and a way to the rest.** Three to
five is where touch targets stop being thumb-sized; eleven over 360px
is 32px each. Which four is plan 016's committed subset, and everything
else — Settings included, because a phone still needs it — is behind
"More".
**The phone section of `index.css` is last on purpose.** A media query
adds no specificity, so a `@media (max-width: 599px)` block placed
above the plain rules it overrides loses to them — which is how phase 1
shipped a header that kept its 2em gutters and 24px title on a 390px
phone with every declaration dead and nothing failing. The shell fitted
anyway, because the fitting is done by `min-width: 0` and by each
component's own media query, which live in their own stylesheets and
have no later rule to lose to. Cosmetic declarations are exactly what
no assertion sees; a screenshot found it.
**`<now-playing-view>` is where the seek bar and volume went.** It is a
*detail* view (`DETAIL_LOADERS`, so the nav stack carries the way out —
a tab you cannot leave by pressing again is not a tab), reached from a
phone-only button over the mini player's art, and it **composes the
real `<seek-bar>`, `<player-controls>` and `<volume-control>`** rather
than reimplementing them. While it is up, `index.css` hides the bottom
bar through `body:has(#main-content[data-active-view="now-playing"])`
the active view is already published as an attribute, and a class
toggled from `index.ts` would be a second expression of the same fact.
The view therefore carries its own queue button, because that button
lives in the bar it hides.
**The playing row is a shape, not a hue.** `track-list` and
`queue-panel` draw a `::before` triangle in each row's own left
padding, plus `aria-current` — before, both rows were a background tint
@@ -1813,10 +1926,25 @@ Feature branches and PRs are the norm, but direct pushes to `main` are allowed.
## CI
Four workflows in `.gitea/workflows/`. Three of them package and
publish (`arch-package`, `homebrew-formula`, `index-artifact`); only
`ci.yml` gates, and it is the one to look at when deciding whether a
push was healthy.
Five workflows in `.gitea/workflows/`. Four of them package and
publish (`arch-package`, `homebrew-formula`, `index-artifact`,
`android-apk`); only `ci.yml` gates, and it is the one to look at when
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
`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
library with it, so the job **refuses to build** without the keystore
secret rather than falling through to Gradle's debug-key default, and
**refuses to publish** an artifact whose certificate says `CN=Android
Debug`. It is deliberately not a job in `ci.yml`: that workflow runs on
every branch push, this one takes tens of minutes on a cold cache, and
the runner has capacity 1. `docs/android-release.md` is the operating
document.
Two jobs, both in an `ubuntu:24.04` container:
@@ -1898,11 +2026,47 @@ four bit the packaging recipes:
**`build/`'s platform metadata is generated from `build/config.yml`.**
`wails3 task common:update:build-assets` rewrites `Info.plist`, the
`.desktop` template, `nfpm.yaml` and the Windows manifest from that
one file — so a hand edit to any of them is lost on the next refresh,
and the two fields it does *not* own (nfpm's `homepage` and `license`)
say so in place. That refresh also regenerates `build/ios/` and
`build/android/`, which this repo does not carry: they are gitignored
rather than deleted-and-rediscovered, and their `includes:` entries
are dropped from `Taskfile.yml`. `build/config.yml`'s `version` is the
one file — so a hand edit to any of them is lost on the next refresh.
nfpm's `homepage` and `license` say in place that the refresh does not
own them, and **that comment is wrong**: a refresh reset them to
`https://wails.io` and `MIT`. Re-check those two after any refresh.
**That refresh does not touch the mobile trees**, contrary to what this
file said for five phases. `update build-assets` extracts only
`updatable_build_assets` (darwin/ios/linux/windows); `build/android/`
and `build/ios/` come from `generate build-assets`, which rewrites the
whole of `build/`. So `build/android/` is **committed and hand-edited
like source** — it was generated once into a scratch directory and
copied across (plan 015), it carries one deliberate edit to its
`Taskfile.yml`, and only its output is gitignored. `build/ios/` is
still not carried and its `includes:` entry is still dropped.
**Its `MainActivity` owns the safe area, because `targetSdk 35` does
not leave that to the theme.** Android 15 lays every app out
edge-to-edge and ignores the `statusBarColor`/`navigationBarColor` the
scaffold's theme sets, and the WebView is `match_parent`, so the page's
bottom band — the transport and, on a phone, the tab bar — would be
drawn under the gesture bar. `applyWindowInsets()` pads the container by
`systemBars | displayCutout | ime` and returns the insets rather than
consuming them; the window background is black to match the app's own
ramp, since that padding is what shows through. It is **pre-emptive**:
the phone this was checked against is Android 14, where the system still
insets the window, and the enforcement applies to an app *running on*
15. No browser tier can see this class of fault either way — a viewport
has no system bars.
**And a device is an engine, not just a screen.** The phone this app was
first run on renders in **Chrome 113** — two years behind every browser
any other tier uses — at a 424x439 CSS px viewport. It has `:has()`,
`color-mix()` and `dialog.showModal()`; it does **not** have relaxed CSS
nesting (Chrome 120, so a nested rule beginning with a bare element
selector is silently dropped), the Popover API (114, which Web Awesome's
popups set `popover="manual"` for), `light-dark()` or relative colour
syntax. So "it renders at that size in Chromium" is not evidence about
the phone, and resizing a spec cannot recover the missing signal. `make
android-inspect` forwards the WebView's devtools socket and `make
android-eval` asks the real page — raw CDP, because `connectOverCDP`
calls `Browser.setDownloadBehavior` and a WebView refuses it.
`build/config.yml`'s `version` is the
*metadata* version and is not what the app reports — `main.version` is
stamped at link time from the packaging recipe's git-derived version.
+58
View File
@@ -38,6 +38,64 @@ dev-stop: ## Stop the headless app (SIGTERM, so shutdown hooks run)
dev-logs: ## Tail the headless app log
@tail -f .dev/app.log
# ---------------------------------------------------------------- #
# The Android tier. See .pi/skills/yellowjacket-dev/references/ #
# android-tier.md for which of these to reach for and why a failure #
# here looks like nothing at all. #
# ---------------------------------------------------------------- #
# The NDK is pinned: r26d is what the pipeline is built and checked
# against, and newer NDKs have broken Wails' Android build before.
# ANDROID_HOME must carry a *platform*, which Arch's /opt/android-sdk
# does not — hence the separate default.
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)
# `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
android-emulator: ## Boot the emulator headless in the background and wait for it
@$(ANDROID_ENV) ./scripts/android-emulator.sh start
android-emulator-stop: ## Shut the emulator down (console kill, then saved PID)
@$(ANDROID_ENV) ./scripts/android-emulator.sh stop
android-install: ## Install bin/yellowjacket.apk onto the running emulator
@$(ANDROID_ENV) ./scripts/android-emulator.sh install
android-launch: ## Force-stop, clear logcat, and start the app
@$(ANDROID_ENV) ./scripts/android-emulator.sh launch
android-logs: ## Tail logcat, filtered to the app's own tags
@$(ANDROID_ENV) ./scripts/android-emulator.sh logs
# The only tier that can see the platform is the one you can look at.
android-screenshot: ## Grab the device screen (OUT=<path>)
@$(ANDROID_ENV) ./scripts/android-emulator.sh screenshot $(OUT)
# The page's own answer, from the engine that is really rendering it.
# Needs the debug build installed (it is a sibling id, so it does not
# disturb the release app): see scripts/android-eval.mjs.
android-inspect: ## Forward the device WebView's devtools socket
@$(ANDROID_ENV) ./scripts/android-emulator.sh inspect
android-eval: ## Evaluate JS in the device WebView (EXPR='...')
@node ./scripts/android-eval.mjs $(if $(EXPR),'$(EXPR)',)
# "Did it start" is the wrong question — a crash-looping app starts
# several times a second. This asserts the *same pid* is still there.
android-smoke: ## Launch and assert the app is still alive (SECONDS=<n>)
@$(ANDROID_ENV) ./scripts/android-emulator.sh smoke $(if $(SECONDS),$(SECONDS),10)
# Seeds are produced by *running the app* — driving the real AddLibrary
# binding and waiting for the real scan — never by hand-writing a
# config.toml and DB rows. A hand-built seed is a second description
+1
View File
@@ -15,6 +15,7 @@ includes:
windows: ./build/windows/Taskfile.yml
darwin: ./build/darwin/Taskfile.yml
linux: ./build/linux/Taskfile.yml
android: ./build/android/Taskfile.yml
tasks:
build:
+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",
+181
View File
@@ -0,0 +1,181 @@
package frontendutil
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"sort"
)
// errNotADirectory is returned when a caller asks to list something
// that exists but is not a directory.
var errNotADirectory = errors.New("not a directory")
// DirEntry is one selectable directory in a listing.
type DirEntry struct {
Name string `json:"name"`
Path string `json:"path"`
}
// DirListing is one level of the filesystem, as a folder picker needs
// it: where we are, what is above, and the directories below.
//
// Parent is empty at a root, which is what tells the UI not to draw an
// "up" affordance rather than having it compute that from the path
// separator.
type DirListing struct {
Path string `json:"path"`
Parent string `json:"parent"`
Entries []DirEntry `json:"entries"`
}
// ListDirectories returns the directories directly inside path, so the
// frontend can draw a folder picker.
//
// **It exists because Android has no directory picker.** Wails' file
// dialog can choose directories on every desktop platform, and on
// Android it returns an error: the Storage Access Framework yields tree
// URIs rather than filesystem paths, and a path is what this app's
// entire library model is keyed on. Rather than teach the backend about
// tree URIs, the app browses the filesystem itself — which it can do
// because it holds all-files access (see the manifest).
//
// Three rules, each of which a picker gets wrong if it is not stated:
// only directories are returned, because the caller is choosing a
// library root and files are noise; unreadable children are skipped
// rather than failing the whole listing, since Android's storage root
// contains directories no app may enter; and hidden directories are
// omitted, because a music library is not in one and `.thumbnails`
// alone would swamp the list.
func (fe *FrontendUtil) ListDirectories(path string) (DirListing, error) {
if path == "" {
path = fe.DefaultBrowseRoot()
}
path = filepath.Clean(path)
info, err := os.Stat(path)
if err != nil {
return DirListing{}, fmt.Errorf("could not open %s: %w", path, err)
}
if !info.IsDir() {
return DirListing{}, fmt.Errorf("%w: %s", errNotADirectory, path)
}
entries, err := os.ReadDir(path)
if err != nil {
return DirListing{}, fmt.Errorf("could not read %s: %w", path, err)
}
dirs := make([]DirEntry, 0, len(entries))
for _, e := range entries {
name := e.Name()
if name == "" || name[0] == '.' {
continue
}
// A symlink reports itself, not its target, so ask the
// filesystem: a symlinked music directory is ordinary and
// skipping it would be a bug the user cannot explain.
child := filepath.Join(path, name)
info, err := os.Stat(child)
if err != nil || !info.IsDir() {
continue
}
dirs = append(dirs, DirEntry{Name: name, Path: child})
}
sort.Slice(dirs, func(i, j int) bool { return dirs[i].Name < dirs[j].Name })
parent := filepath.Dir(path)
if parent == path {
parent = ""
}
return DirListing{Path: path, Parent: parent, Entries: dirs}, nil
}
// androidSharedStorage is where a user's music lives on Android. It is
// not derivable from the environment the way a desktop home directory
// is: HOME inside an Android app process is "/", so os.UserHomeDir()
// would start the picker at the filesystem root with nothing readable
// under it.
const androidSharedStorage = "/storage/emulated/0"
// DefaultBrowseRoot is where a folder picker should open.
func (fe *FrontendUtil) DefaultBrowseRoot() string {
if runtime.GOOS == "android" {
for _, candidate := range []string{androidSharedStorage, "/storage"} {
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
return candidate
}
}
return "/"
}
if home, err := os.UserHomeDir(); err == nil && home != "" {
return home
}
return string(filepath.Separator)
}
// StorageAccess reports whether the app can actually read the place the
// user's music lives.
type StorageAccess struct {
Root string `json:"root"`
Readable bool `json:"readable"`
Reason string `json:"reason"`
}
// CheckStorageAccess asks the filesystem rather than the permission
// system.
//
// On Android this app holds MANAGE_EXTERNAL_STORAGE, which the user
// grants on a Settings screen rather than in a dialog — so it can be
// refused, revoked later, or simply never answered, and the permission
// API is one more thing that can disagree with reality. Reading the
// directory is the question the library scanner will actually ask, so
// it is the one worth answering.
//
// It is deliberately not an error return: "we cannot read your music
// yet" is a state the UI renders, not a failure of the call.
func (fe *FrontendUtil) CheckStorageAccess() StorageAccess {
root := fe.DefaultBrowseRoot()
if _, err := os.ReadDir(root); err != nil {
return StorageAccess{
Root: root,
Readable: false,
Reason: err.Error(),
}
}
return StorageAccess{Root: root, Readable: true}
}
// HasNativeDirectoryPicker reports whether this platform can open a
// directory dialog at all.
//
// It is asked of the backend rather than tested in the frontend with
// `System.IsAndroid()`, for three reasons. The dialog *is* backend code
// — `DirectoryPicker` above — so this is the same package saying what
// it can do. It answers for iOS too without the frontend enumerating
// platforms. And it makes the frontend's fallback testable through the
// ordinary transport fake instead of a module mock of the Wails
// runtime, whose platform helpers read build constants.
func (fe *FrontendUtil) HasNativeDirectoryPicker() bool {
switch runtime.GOOS {
case "android", "ios":
return false
default:
return true
}
}
+207
View File
@@ -0,0 +1,207 @@
package frontendutil
import (
"os"
"path/filepath"
"runtime"
"testing"
)
// listing helper: a tree with the three shapes the picker has to get
// right — an ordinary directory, a file (never listed), and a hidden
// directory (never listed).
func browseFixture(t *testing.T) string {
t.Helper()
root := t.TempDir()
for _, dir := range []string{"Music", "Podcasts", "aaa", ".thumbnails"} {
if err := os.Mkdir(filepath.Join(root, dir), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
}
if err := os.WriteFile(filepath.Join(root, "track.mp3"), []byte("x"), 0o600); err != nil {
t.Fatalf("write file: %v", err)
}
return root
}
func TestListDirectories(t *testing.T) {
fe := &FrontendUtil{}
root := browseFixture(t)
got, err := fe.ListDirectories(root)
if err != nil {
t.Fatalf("ListDirectories: %v", err)
}
// Sorted, directories only, no file and no dotted entry.
want := []string{"Music", "Podcasts", "aaa"}
if len(got.Entries) != len(want) {
t.Fatalf("got %d entries %v, want %v", len(got.Entries), got.Entries, want)
}
for i, w := range want {
if got.Entries[i].Name != w {
t.Errorf("entry %d = %q, want %q", i, got.Entries[i].Name, w)
}
if got.Entries[i].Path != filepath.Join(root, w) {
t.Errorf("entry %d path = %q, want %q", i, got.Entries[i].Path, filepath.Join(root, w))
}
}
if got.Path != root {
t.Errorf("Path = %q, want %q", got.Path, root)
}
if got.Parent != filepath.Dir(root) {
t.Errorf("Parent = %q, want %q", got.Parent, filepath.Dir(root))
}
}
// A symlink reports itself rather than its target, so a listing that
// trusts DirEntry.IsDir() silently drops a symlinked music folder --
// which is an ordinary thing to have and an unexplainable thing to
// lose.
func TestListDirectoriesFollowsSymlinks(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks need elevation on Windows")
}
root := t.TempDir()
target := filepath.Join(root, "real")
if err := os.Mkdir(target, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
link := filepath.Join(root, "linked")
if err := os.Symlink(target, link); err != nil {
t.Fatalf("symlink: %v", err)
}
fe := &FrontendUtil{}
got, err := fe.ListDirectories(root)
if err != nil {
t.Fatalf("ListDirectories: %v", err)
}
if len(got.Entries) != 2 {
t.Fatalf("got %v, want both 'linked' and 'real'", got.Entries)
}
}
// A dangling symlink, and anything else os.Stat refuses, must be
// skipped rather than failing the whole listing: Android's storage
// root holds directories no app may enter, and one of them must not
// cost the user the picker.
func TestListDirectoriesSkipsUnreadable(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks need elevation on Windows")
}
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, "good"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
dangling := filepath.Join(root, "dangling")
if err := os.Symlink(filepath.Join(root, "nowhere"), dangling); err != nil {
t.Fatalf("symlink: %v", err)
}
fe := &FrontendUtil{}
got, err := fe.ListDirectories(root)
if err != nil {
t.Fatalf("ListDirectories: %v", err)
}
if len(got.Entries) != 1 || got.Entries[0].Name != "good" {
t.Errorf("got %v, want only 'good'", got.Entries)
}
}
func TestListDirectoriesRejectsFiles(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "track.mp3")
if err := os.WriteFile(file, []byte("x"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
fe := &FrontendUtil{}
if _, err := fe.ListDirectories(file); err == nil {
t.Error("listing a file should be an error")
}
if _, err := fe.ListDirectories(filepath.Join(root, "missing")); err == nil {
t.Error("listing a missing path should be an error")
}
}
// An empty path means "start where the picker should open", so the
// frontend never has to know the platform.
func TestListDirectoriesDefaultsToBrowseRoot(t *testing.T) {
fe := &FrontendUtil{}
got, err := fe.ListDirectories("")
if err != nil {
t.Skipf("default root not listable in this environment: %v", err)
}
if got.Path != fe.DefaultBrowseRoot() {
t.Errorf("Path = %q, want the default root %q", got.Path, fe.DefaultBrowseRoot())
}
}
// Parent is empty at a root, which is what tells the picker not to draw
// an "up" control rather than making it reason about separators.
func TestListDirectoriesRootHasNoParent(t *testing.T) {
fe := &FrontendUtil{}
got, err := fe.ListDirectories(string(filepath.Separator))
if err != nil {
t.Skipf("filesystem root not listable: %v", err)
}
if got.Parent != "" {
t.Errorf("Parent = %q at the root, want empty", got.Parent)
}
}
func TestCheckStorageAccess(t *testing.T) {
fe := &FrontendUtil{}
got := fe.CheckStorageAccess()
if got.Root == "" {
t.Error("Root should never be empty")
}
// The developer machine running this test can read its own home
// directory; the assertion is that the two fields agree, not that
// access is granted.
if got.Readable && got.Reason != "" {
t.Errorf("readable but Reason = %q", got.Reason)
}
if !got.Readable && got.Reason == "" {
t.Error("not readable but no Reason given")
}
}
// The picker's fallback is chosen from this, so a platform that gains
// a working dialog must flip it here rather than in the frontend.
func TestHasNativeDirectoryPicker(t *testing.T) {
fe := &FrontendUtil{}
want := runtime.GOOS != "android" && runtime.GOOS != "ios"
if got := fe.HasNativeDirectoryPicker(); got != want {
t.Errorf("HasNativeDirectoryPicker() on %s = %v, want %v", runtime.GOOS, got, want)
}
}
+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.01.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.
+8 -1
View File
@@ -1,4 +1,11 @@
//go:build linux
//go:build linux && !android
// MPRIS is a D-Bus desktop specification, and `android` implies the
// `linux` build tag -- so without the `!android` this file compiled
// into the Android app and went looking for a session bus that does
// not exist. Desktop-Linux-only files need both halves; see Wails'
// mobile guide, which names this as the Android analogue of
// ios/darwin.
package mediacontrols
+5
View File
@@ -1,5 +1,10 @@
//go:build !linux
// 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
import "log/slog"
+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)
}
}
+23
View File
@@ -29,6 +29,29 @@ const (
// without touching the current user's real config.toml or yj.db.
const envHomeOverride = "YJ_HOME"
// UseHomeOverride points every config and data path at base, by setting
// the same override a development sandbox uses.
//
// It exists for mobile, where the switch in buildUserDirPath has no
// answer: there is no home directory and no XDG, only a per-app private
// directory the OS hands out at runtime. The caller is main(), which is
// the only place that can ask the platform for it — deliberately, so
// this package stays free of the Wails application package that knows
// (see backend/events' indexbuild split for why that matters).
//
// Two rules. An empty base is a no-op, because that is exactly what
// application.Mobile.StoragePath() returns on desktop. And an override
// that is already set wins, so YJ_HOME on the command line still
// relocates a sandbox on a platform that would otherwise decide for
// itself.
func UseHomeOverride(base string) {
if base == "" || os.Getenv(envHomeOverride) != "" {
return
}
_ = os.Setenv(envHomeOverride, base)
}
// getUserDirPath returns and creates the path for a user directory.
func getUserDirPath(dt dirType) (string, error) {
path, err := resolveUserDirPath(dt)
+37
View File
@@ -1,6 +1,7 @@
package system
import (
"os"
"path/filepath"
"strings"
"testing"
@@ -50,3 +51,39 @@ func TestResolveUserDirPath_NoOverrideUsesOSPath(t *testing.T) {
)
}
}
// UseHomeOverride carries two rules that a mobile launch depends on and
// that nothing else would notice breaking: an empty base must do
// nothing, because that is precisely what StoragePath() returns on
// desktop, and an override already set must win, or YJ_HOME would stop
// relocating a sandbox on the platform that decides for itself.
func TestUseHomeOverride(t *testing.T) {
const (
storage = "/data/user/0/app.yellowjacket/files"
sandbox = "/tmp/sandbox"
)
tests := []struct {
name string
already string
base string
want string
}{
{name: "empty base is a no-op", already: "", base: "", want: ""},
{name: "sets the override when unset", already: "", base: storage, want: storage},
{name: "an existing override wins", already: sandbox, base: storage, want: sandbox},
{name: "empty base keeps an existing override", already: sandbox, base: "", want: sandbox},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv(envHomeOverride, tt.already)
UseHomeOverride(tt.base)
if got := os.Getenv(envHomeOverride); got != tt.want {
t.Errorf("%s = %q, want %q", envHomeOverride, got, tt.want)
}
})
}
}
+486
View File
@@ -0,0 +1,486 @@
version: '3'
includes:
common: ../Taskfile.yml
vars:
# The *installed* package name, which every adb-driven task below uses
# to uninstall, launch and filter. It must agree with `applicationId`
# in app/build.gradle, and nothing enforces that.
#
# ANDROID.md says to set this in build/config.yml. That does not work
# in beta.8, checked both ways: `wails3 task` builds its var set from
# CLI KEY=VALUE arguments and the Taskfile tree only -- nothing reads
# config.yml -- and even when set it feeds only these adb commands,
# never Gradle. So the identity is declared twice, here and in
# build.gradle, and a change to one alone means the official run and
# deploy tasks address a package that is not installed.
APP_ID: '{{.APP_ID | default "app.yellowjacket"}}'
MIN_SDK: '21'
TARGET_SDK: '35'
# The emulator runs the host architecture; physical devices are arm64
HOST_ARCH:
sh: '[ "$(uname -m)" = "x86_64" ] && echo "amd64" || echo "arm64"'
# System-image ABI for the host, used in the "create an AVD" hint below.
ANDROID_ABI:
sh: '[ "$(uname -m)" = "arm64" ] && echo "arm64-v8a" || echo "x86_64"'
# SDK location: $ANDROID_HOME / $ANDROID_SDK_ROOT, else the per-OS default
# (macOS: ~/Library/Android/sdk, Linux/other: ~/Android/Sdk)
SDK_ROOT:
sh: 'echo "${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$([ -d "$HOME/Library/Android/sdk" ] && echo "$HOME/Library/Android/sdk" || echo "$HOME/Android/Sdk")}}"'
ADB:
sh: 'command -v adb || echo "${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$([ -d "$HOME/Library/Android/sdk" ] && echo "$HOME/Library/Android/sdk" || echo "$HOME/Android/Sdk")}}/platform-tools/adb"'
EMULATOR:
sh: 'command -v emulator || echo "${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$([ -d "$HOME/Library/Android/sdk" ] && echo "$HOME/Library/Android/sdk" || echo "$HOME/Android/Sdk")}}/emulator/emulator"'
# avdmanager lives under cmdline-tools/<version>/bin; used to auto-create an
# AVD when none exists (mirrors the iOS `ensure-simulator` auto-create flow).
AVDMANAGER:
sh: 'command -v avdmanager || ls "${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$([ -d "$HOME/Library/Android/sdk" ] && echo "$HOME/Library/Android/sdk" || echo "$HOME/Android/Sdk")}}"/cmdline-tools/*/bin/avdmanager 2>/dev/null | sort -V | tail -1 || true'
tasks:
install:deps:
summary: Check and install Android development dependencies
cmds:
- go run build/android/scripts/deps/install_deps.go
env:
TASK_FORCE_YES: '{{if .YES}}true{{else}}false{{end}}'
prompt: This will check and install Android development dependencies. Continue?
build:
summary: Creates a debug build of the application for Android
deps:
- task: common:go:mod:tidy
- task: generate:android:overlay
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
PRODUCTION:
ref: .PRODUCTION
cmds:
- echo "Building Android app {{.APP_NAME}}..."
- task: compile:go:shared
vars:
ARCH: '{{.ARCH | default .HOST_ARCH}}'
# This repo's one edit to the android scaffold, and it is not
# cosmetic. Upstream forwards ARCH here and not PRODUCTION, so
# compile:go:shared recomputed BUILD_FLAGS against an unset
# .PRODUCTION and fell back to the debug branch. package:fat
# calls compile:go:shared directly for amd64 (passing it), and
# reaches arm64 only through this task -- so a release APK
# shipped a *debug* 40 MB arm64 library beside a production
# 31 MB x86_64 one. The phone ABI, which is the only one a
# release is for, was the broken one. 34 MB APK before, 27
# after.
PRODUCTION: '{{.PRODUCTION}}'
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production,android -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-tags android,debug -buildvcs=false -gcflags=all="-l"{{end}}'
env:
PRODUCTION: '{{.PRODUCTION | default "false"}}'
compile:go:shared:
summary: Compile Go code to shared library (.so)
cmds:
- |
# Locate the NDK: $ANDROID_NDK_HOME, or the newest installed NDK
NDK_ROOT="$ANDROID_NDK_HOME"
if [ -z "$NDK_ROOT" ]; then
SDK_ROOT="{{.SDK_ROOT}}"
NDK_ROOT=$(ls -d "$SDK_ROOT"/ndk/* 2>/dev/null | sort -V | tail -1)
fi
if [ -z "$NDK_ROOT" ] || [ ! -d "$NDK_ROOT" ]; then
echo "Error: Android NDK not found"
echo "Install one with: sdkmanager 'ndk;26.3.11579264' (or set ANDROID_NDK_HOME)"
exit 1
fi
# Determine toolchain based on host OS
case "$(uname -s)" in
Darwin) HOST_TAG="darwin-x86_64" ;;
Linux) HOST_TAG="linux-x86_64" ;;
*) echo "Unsupported host OS"; exit 1 ;;
esac
TOOLCHAIN="$NDK_ROOT/toolchains/llvm/prebuilt/$HOST_TAG"
# Set compiler based on architecture
case "{{.ARCH}}" in
arm64)
export CC="$TOOLCHAIN/bin/aarch64-linux-android{{.MIN_SDK}}-clang"
export CXX="$TOOLCHAIN/bin/aarch64-linux-android{{.MIN_SDK}}-clang++"
export GOARCH=arm64
JNI_DIR="arm64-v8a"
;;
amd64|x86_64)
export CC="$TOOLCHAIN/bin/x86_64-linux-android{{.MIN_SDK}}-clang"
export CXX="$TOOLCHAIN/bin/x86_64-linux-android{{.MIN_SDK}}-clang++"
export GOARCH=amd64
JNI_DIR="x86_64"
;;
*)
echo "Unsupported architecture: {{.ARCH}}"
exit 1
;;
esac
export CGO_ENABLED=1
export GOOS=android
mkdir -p {{.BIN_DIR}}
mkdir -p build/android/app/src/main/jniLibs/$JNI_DIR
go build -buildmode=c-shared -overlay build/android/overlay.json {{.BUILD_FLAGS}} \
-o build/android/app/src/main/jniLibs/$JNI_DIR/libwails.so
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production,android -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-tags android,debug -buildvcs=false -gcflags=all="-l"{{end}}'
compile:go:all-archs:
summary: Compile Go code for all Android architectures (fat APK)
cmds:
- task: compile:go:shared
vars:
ARCH: arm64
- task: compile:go:shared
vars:
ARCH: amd64
package:
summary: Packages a production build of the application into a signed release APK
desc: |
Builds for arm64 by default (covers 99%+ of real devices). Set ARCH=amd64
for emulator-only APKs, or use package:fat for a universal APK.
deps:
- task: build
vars:
PRODUCTION: "true"
ARCH: '{{.ARCH | default "arm64"}}'
cmds:
- task: assemble:apk:release
package:fat:
summary: Packages a production build for all architectures (fat APK)
deps:
- task: build
vars:
PRODUCTION: "true"
ARCH: arm64
cmds:
- task: compile:go:shared
vars:
ARCH: amd64
PRODUCTION: "true"
- task: assemble:apk:release
bundle:
summary: Packages a production AAB (Android App Bundle) for Play Store submission
desc: |
Builds for arm64 by default. Set ARCH=amd64 for emulator builds, or use
bundle:fat for a universal AAB.
deps:
- task: build
vars:
PRODUCTION: "true"
ARCH: '{{.ARCH | default "arm64"}}'
cmds:
- task: assemble:aab:release
bundle:fat:
summary: Packages a production AAB for all architectures
deps:
- task: build
vars:
PRODUCTION: "true"
ARCH: arm64
cmds:
- task: compile:go:shared
vars:
ARCH: amd64
PRODUCTION: "true"
- task: assemble:aab:release
assemble:apk:
summary: Assembles a debug APK using Gradle
preconditions:
- sh: 'command -v java >/dev/null || [ -n "$JAVA_HOME" ]'
msg: "Java not found. Install a JDK (e.g. brew install openjdk@21) and/or set JAVA_HOME"
cmds:
- |
cd build/android
# The exec bit is lost when gradlew is extracted from the embedded
# build assets, so restore it before invoking the wrapper.
chmod +x ./gradlew
./gradlew assembleDebug
cp app/build/outputs/apk/debug/app-debug.apk "../../{{.BIN_DIR}}/{{.APP_NAME}}.apk"
echo "APK created: {{.BIN_DIR}}/{{.APP_NAME}}.apk"
assemble:apk:release:
summary: Assembles a release APK using Gradle (signed with the debug keystore unless ANDROID_KEYSTORE_FILE is set)
preconditions:
- sh: 'command -v java >/dev/null || [ -n "$JAVA_HOME" ]'
msg: "Java not found. Install a JDK (e.g. brew install openjdk@21) and/or set JAVA_HOME"
cmds:
- |
cd build/android
# The exec bit is lost when gradlew is extracted from the embedded
# build assets, so restore it before invoking the wrapper.
chmod +x ./gradlew
./gradlew assembleRelease
cp app/build/outputs/apk/release/app-release.apk "../../{{.BIN_DIR}}/{{.APP_NAME}}.apk"
echo "Release APK created: {{.BIN_DIR}}/{{.APP_NAME}}.apk"
assemble:aab:
summary: Assembles a debug AAB (Android App Bundle) using Gradle
preconditions:
- sh: 'command -v java >/dev/null || [ -n "$JAVA_HOME" ]'
msg: "Java not found. Install a JDK (e.g. brew install openjdk@21) and/or set JAVA_HOME"
cmds:
- |
cd build/android
# The exec bit is lost when gradlew is extracted from the embedded
# build assets, so restore it before invoking the wrapper.
chmod +x ./gradlew
./gradlew bundleDebug
cp app/build/outputs/bundle/debug/app-debug.aab "../../{{.BIN_DIR}}/{{.APP_NAME}}.aab"
echo "AAB created: {{.BIN_DIR}}/{{.APP_NAME}}.aab"
assemble:aab:release:
summary: Assembles a release AAB for Play Store upload (signed with the debug keystore unless ANDROID_KEYSTORE_FILE is set)
preconditions:
- sh: 'command -v java >/dev/null || [ -n "$JAVA_HOME" ]'
msg: "Java not found. Install a JDK (e.g. brew install openjdk@21) and/or set JAVA_HOME"
cmds:
- |
# With Play App Signing, the keystore configured here is your UPLOAD
# key: Google verifies the upload with it, then re-signs the app with
# the app signing key it manages for distribution.
if [ -z "$ANDROID_KEYSTORE_FILE" ]; then
echo "WARNING: ANDROID_KEYSTORE_FILE is not set, so this AAB will be"
echo "signed with the debug keystore. Google Play rejects debug-signed"
echo "bundles. Set ANDROID_KEYSTORE_FILE, ANDROID_KEYSTORE_PASSWORD,"
echo "ANDROID_KEY_ALIAS and ANDROID_KEY_PASSWORD before uploading."
fi
cd build/android
# The exec bit is lost when gradlew is extracted from the embedded
# build assets, so restore it before invoking the wrapper.
chmod +x ./gradlew
./gradlew bundleRelease
cp app/build/outputs/bundle/release/app-release.aab "../../{{.BIN_DIR}}/{{.APP_NAME}}.aab"
echo "Release AAB created: {{.BIN_DIR}}/{{.APP_NAME}}.aab"
generate:android:overlay:
internal: true
summary: Generate Go build overlay that registers the Android main
sources:
- build/config.yml
generates:
- build/android/overlay.json
- build/android/gen/main_android.gen.go
cmds:
- wails3 android overlay:gen -out build/android/overlay.json -config build/config.yml
generate:android:bindings:
internal: true
summary: Generates bindings for Android
sources:
- "**/*.go"
- go.mod
- go.sum
generates:
- frontend/bindings/**/*
cmds:
# Bindings are generated from the Go AST; CGO is disabled so the NDK
# is not required for this step
- wails3 generate bindings -f '-tags android' -clean=true
env:
GOOS: android
CGO_ENABLED: 0
ensure-emulator:
internal: true
summary: Ensure Android Emulator is running
silent: true
cmds:
- |
# Check if an emulator is already running
if "{{.ADB}}" devices | grep -q "emulator"; then
echo "Emulator already running"
exit 0
fi
# Get first available AVD
AVD_NAME=$("{{.EMULATOR}}" -list-avds | tail -1)
if [ -z "$AVD_NAME" ]; then
# No AVD yet. Mirror the iOS `ensure-simulator` flow and create one
# automatically — but ONLY from a system image that is already
# installed. We never trigger an sdkmanager download from a `run`
# task (that would be a surprise multi-GB download + license prompt).
# Pick the highest-API installed image matching the host ABI.
SDK_ROOT="{{.SDK_ROOT}}"
ABI="{{.ANDROID_ABI}}"
IMG=$(ls -d "$SDK_ROOT"/system-images/android-*/*/"$ABI" 2>/dev/null | sort -V | tail -1)
AVDMANAGER="{{.AVDMANAGER}}"
if [ -n "$IMG" ] && [ -x "$AVDMANAGER" ]; then
PKG="system-images;$(echo "$IMG" | sed "s|$SDK_ROOT/system-images/||" | tr '/' ';')"
echo "No Android Virtual Devices found. Creating 'wails' from $PKG..."
echo "no" | "$AVDMANAGER" create avd --name wails --package "$PKG" --device pixel_7 --force
AVD_NAME=wails
else
echo "No Android Virtual Devices found, and no system image is installed to create one from."
echo "Install a system image and create an AVD, e.g.:"
echo " sdkmanager 'system-images;android-35;google_apis;$ABI'"
echo " avdmanager create avd --name wails --package 'system-images;android-35;google_apis;$ABI' --device pixel_7"
exit 1
fi
fi
echo "Starting emulator: $AVD_NAME"
# Start the emulator daemonized so it outlives this task step. go-task's
# shell tracks background jobs by PID and reaps them when the command's
# interpreter finishes (which nohup/setsid alone don't prevent — the kill
# is direct), so a bare `emulator &` is gone before the later
# install/launch steps run. Launch it from a short-lived child shell that
# backgrounds the emulator and exits immediately: the emulator is then
# reparented to init/launchd and go-task's shell has no handle to reap it.
nohup sh -c "'{{.EMULATOR}}' -avd '$AVD_NAME' -no-snapshot-load </dev/null >/dev/null 2>&1 &" >/dev/null 2>&1
# Wait for emulator to boot (max 120 seconds)
echo "Waiting for emulator to boot..."
"{{.ADB}}" wait-for-device
for i in $(seq 1 120); do
BOOT_COMPLETED=$("{{.ADB}}" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')
if [ "$BOOT_COMPLETED" = "1" ]; then
echo "Emulator booted successfully"
exit 0
fi
sleep 1
done
echo "Emulator boot timeout"
exit 1
preconditions:
- sh: '[ -x "{{.ADB}}" ] || command -v adb'
msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)"
- sh: '[ -x "{{.EMULATOR}}" ] || command -v emulator'
msg: "emulator not found. Install the Android SDK emulator package (or set ANDROID_HOME)"
deploy-emulator:
summary: Deploy the packaged release APK to the Android Emulator
deps:
- task: package
vars:
ARCH: '{{.ARCH | default .HOST_ARCH}}'
cmds:
- task: ensure-emulator
- '"{{.ADB}}" uninstall {{.APP_ID}} 2>/dev/null || true'
- '"{{.ADB}}" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"'
- '"{{.ADB}}" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity'
run:
summary: Build, install and launch a debug build in the Android Emulator
deps:
- task: ensure-emulator
- task: build
cmds:
- task: assemble:apk
- '"{{.ADB}}" uninstall {{.APP_ID}} 2>/dev/null || true'
- '"{{.ADB}}" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"'
- '"{{.ADB}}" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity'
device:list:
summary: Lists connected Android devices and emulators (serials)
cmds:
- '"{{.ADB}}" devices -l'
run:device:
summary: Build, install and launch a debug build on a connected physical Android device
deps:
- task: build
vars:
ARCH: arm64
cmds:
- task: assemble:apk
- |
DEVICE='{{.DEVICE_ID | default ""}}'
if [ -z "$DEVICE" ]; then
DEVICE="${DEVICE_ID:-}"
fi
if [ -z "$DEVICE" ]; then
DEVICE=$("{{.ADB}}" devices | awk 'NR > 1 && $2 == "device" && $1 !~ /^emulator-/ { print $1; exit }')
fi
if [ -z "$DEVICE" ]; then
echo "Error: no connected physical Android device found."
echo "Pass DEVICE_ID=<serial> to target a device explicitly."
echo "Find connected device serials with: {{.ADB}} devices"
exit 1
fi
echo "Deploying {{.BIN_DIR}}/{{.APP_NAME}}.apk to device $DEVICE..."
"{{.ADB}}" -s "$DEVICE" uninstall {{.APP_ID}} 2>/dev/null || true
"{{.ADB}}" -s "$DEVICE" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"
"{{.ADB}}" -s "$DEVICE" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity
preconditions:
- sh: '[ -x "{{.ADB}}" ] || command -v adb'
msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)"
deploy-device:
summary: Deploy the packaged release APK to a connected physical Android device
deps:
- task: package
vars:
ARCH: arm64
cmds:
- |
DEVICE='{{.DEVICE_ID | default ""}}'
if [ -z "$DEVICE" ]; then
DEVICE="${DEVICE_ID:-}"
fi
if [ -z "$DEVICE" ]; then
DEVICE=$("{{.ADB}}" devices | awk 'NR > 1 && $2 == "device" && $1 !~ /^emulator-/ { print $1; exit }')
fi
if [ -z "$DEVICE" ]; then
echo "Error: no connected physical Android device found."
echo "Pass DEVICE_ID=<serial> to target a device explicitly."
echo "Find connected device serials with: {{.ADB}} devices"
exit 1
fi
echo "Deploying {{.BIN_DIR}}/{{.APP_NAME}}.apk to device $DEVICE..."
"{{.ADB}}" -s "$DEVICE" uninstall {{.APP_ID}} 2>/dev/null || true
"{{.ADB}}" -s "$DEVICE" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"
"{{.ADB}}" -s "$DEVICE" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity
preconditions:
- sh: '[ -x "{{.ADB}}" ] || command -v adb'
msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)"
studio:
summary: Open the generated Android project in Android Studio
cmds:
- |
if command -v studio >/dev/null 2>&1; then
studio build/android
elif [ -d "/Applications/Android Studio.app" ]; then
open -a "Android Studio" build/android
else
echo "Android Studio not found. Install it from https://developer.android.com/studio,"
echo "then open the build/android directory."
exit 1
fi
logs:
summary: Stream Android logcat filtered to this app
cmds:
- '"{{.ADB}}" logcat -v time | grep -E "(Wails|{{.APP_NAME}})" || true'
logs:all:
summary: Stream all Android logcat (verbose)
cmds:
- '"{{.ADB}}" logcat -v time'
clean:
summary: Clean build artifacts
cmds:
- rm -rf {{.BIN_DIR}}
- rm -rf build/android/app/build
- rm -rf build/android/app/src/main/jniLibs/*/libwails.so
- rm -rf build/android/.gradle
+127
View File
@@ -0,0 +1,127 @@
plugins {
id 'com.android.application'
}
android {
namespace 'com.wails.app'
compileSdk 35
buildFeatures {
buildConfig = true
}
defaultConfig {
// The app's identity on the device. `namespace` above stays
// com.wails.app -- that is the *Java package* the scaffold's
// MainActivity/WailsBridge live in, and renaming it would mean
// renaming their source. The two being different is normal and is
// why every `am start` needs the fully-qualified activity name
// (app.yellowjacket/com.wails.app.MainActivity), not `.MainActivity`.
//
// Matches build/config.yml's productIdentifier.
applicationId "app.yellowjacket"
minSdk 21
targetSdk 35
// **Android orders releases by this integer, not by the version
// string, and refuses to install anything not greater than what is
// already there.** A hardcoded 1 means the first install is the
// last: every later build is rejected as a downgrade and the only
// way out is an uninstall, which takes the user's library with it.
// CI derives it from the tag (1.3.1 -> 10301), monotonic as long as
// minor and patch stay under 100. The defaults keep a local build
// working with no environment at all.
//
// `Integer.parseInt`, not `(...) as Integer`: Groovy binds the call
// parentheses to `versionCode` before the cast, so the latter reads
// as `versionCode("1") as Integer` -- it sets a String, then casts
// the setter's null return, and Gradle fails the entire project
// with "Value is null" pointing at this line.
versionCode Integer.parseInt(System.getenv("YJ_VERSION_CODE") ?: "1")
versionName System.getenv("YJ_VERSION") ?: "0.0.0"
// **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'
}
}
def keystoreFile = System.getenv("ANDROID_KEYSTORE_FILE")
def hasKeystore = keystoreFile != null && !keystoreFile.trim().isEmpty()
signingConfigs {
// A real keystore can be provided via environment variables; without
// one, release builds are signed with the debug keystore so they can
// be installed for testing (not suitable for Play Store uploads).
release {
if (hasKeystore) {
storeFile file(keystoreFile)
storePassword System.getenv("ANDROID_KEYSTORE_PASSWORD")
keyAlias System.getenv("ANDROID_KEY_ALIAS")
keyPassword System.getenv("ANDROID_KEY_PASSWORD")
}
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig hasKeystore ? signingConfigs.release : signingConfigs.debug
}
debug {
debuggable true
// Its own application id, so it installs *beside* the release
// app rather than needing an uninstall to replace it. The two
// are signed by different certificates (the release one comes
// from a keystore CI holds), and Android's remedy for a
// certificate change is an uninstall -- which takes the
// user's library with it. This is also what makes the WebView
// inspectable on a real phone: `debuggable` is what turns on
// `setWebContentsDebuggingEnabled`, and `make android-inspect`
// drives it.
applicationIdSuffix ".dev"
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
// Source sets configuration
sourceSets {
main {
// JNI libraries are in jniLibs folder
jniLibs.srcDirs = ['src/main/jniLibs']
// Assets for the WebView
assets.srcDirs = ['src/main/assets']
}
}
// Packaging options
packagingOptions {
// Don't strip Go symbols in debug builds
doNotStrip '*/arm64-v8a/libwails.so'
}
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.webkit:webkit:1.9.0'
implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.biometric:biometric:1.1.0'
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
}
+12
View File
@@ -0,0 +1,12 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
# Keep native methods
-keepclasseswithmembernames class * {
native <methods>;
}
# Keep Wails bridge classes
-keep class com.wails.app.WailsBridge { *; }
-keep class com.wails.app.WailsJSBridge { *; }
@@ -0,0 +1,111 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Internet permission for WebView -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Observe network connectivity / type for android:NetworkChanged events -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!--
Playback has to survive the screen locking, and that needs a
foreground service typed mediaPlayback rather than dataSync. The
type in the <service> element and the permission here must agree,
or startForeground throws at runtime.
-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!--
Reading the user's music.
READ_MEDIA_AUDIO is the Android 13+ grant and READ_EXTERNAL_STORAGE
is its predecessor, capped so it is not requested where it no
longer applies. Both give access through **MediaStore**.
MANAGE_EXTERNAL_STORAGE is what gives access through the
*filesystem*, and this app needs it rather than merely preferring
it: `audio_files.file_path` is the primary key of ownership, the
scanner walks a directory the user chose, and every
GetFilePathsBy... query exists to hand a path to the player.
MediaStore offers no stable directory to walk and no way to write
a tag back in place, so the alternative is not "more work" but a
different application.
It is a Play-restricted permission, granted on a Settings screen
rather than in a dialog. That is acceptable *here* only because
this app is distributed as an APK through the package registry and
not through Play — see docs/android-release.md. If it ever targets
Play, this is the line that has to go, and plan 016 says what
would replace it.
-->
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<queries>
<intent>
<action android:name="android.media.action.IMAGE_CAPTURE" />
</intent>
<intent>
<action android:name="android.media.action.VIDEO_CAPTURE" />
</intent>
</queries>
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.WailsApp"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboardHidden|uiMode"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!--
mediaPlayback, not the scaffold's dataSync: this app's reason
for staying alive in the background is that a song is
playing, and Android matches the declared type against what
the service actually does.
-->
<service
android:name=".WailsForegroundService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
</application>
</manifest>
@@ -0,0 +1,967 @@
package com.wails.app;
import android.annotation.SuppressLint;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.res.Configuration;
import android.database.Cursor;
import android.net.ConnectivityManager;
import android.net.Network;
import android.net.NetworkCapabilities;
import android.net.Uri;
import android.Manifest;
import android.os.BatteryManager;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.os.PowerManager;
import android.provider.Settings;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.provider.MediaStore;
import android.provider.OpenableColumns;
import android.util.Base64;
import android.util.Log;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.view.View;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.FileProvider;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import androidx.webkit.WebViewAssetLoader;
import org.json.JSONObject;
import java.io.File;
import java.io.FileOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.List;
/**
* MainActivity hosts the WebView and manages the Wails application lifecycle.
* It uses WebViewAssetLoader to serve assets from the Go library without
* requiring a network server.
*/
public class MainActivity extends AppCompatActivity {
private static final String TAG = "WailsActivity";
private static final boolean DEBUG = BuildConfig.DEBUG;
private static final String WAILS_SCHEME = "https";
private static final String WAILS_HOST = "wails.localhost";
private static final int FILE_PICKER_REQUEST = 7001;
private WebView webView;
private WailsBridge bridge;
// Battery: system-event receivers are registered only while the activity is
// in the foreground (onStart) and torn down in onStop, so background battery/
// network/screen broadcasts don't wake the app.
private boolean systemReceiversRegistered = false;
private WebViewAssetLoader assetLoader;
// The Go-side dialog ID of the in-flight file picker (-1 when idle)
private int pendingFilePickerCallbackID = -1;
private static final int PHOTO_CAPTURE_REQUEST = 7002;
private static final int VIDEO_CAPTURE_REQUEST = 7003;
private static final int CAMERA_PERMISSION_REQUEST = 7010;
private File pendingCaptureFile;
private boolean pendingCaptureIsVideo;
// System-event sources (battery/power, screen lock, network). Registered in
// onCreate, torn down in onDestroy. Each forwards a "system:*" event to JS
// via the bridge.
private BroadcastReceiver batteryReceiver;
private BroadcastReceiver screenReceiver;
private BroadcastReceiver powerSaveReceiver;
private ConnectivityManager connectivityManager;
private ConnectivityManager.NetworkCallback networkCallback;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// Before anything renders: the page is laid out inside the
// window, and on Android 15 the window is the whole screen.
applyWindowInsets();
// Initialize the native Go library
bridge = new WailsBridge(this);
bridge.initialize();
// Ask for access to the user's music before the frontend has
// anything to say about it. See ensureStorageAccess().
ensureStorageAccess();
// Set up WebView
setupWebView();
// Load the application
loadApplication();
}
/**
* Obtain access to the user's music.
*
* <p>This app is a library manager: its database is keyed on file
* paths, its scanner walks a directory the user chose, and its tag
* writer rewrites files in place. MediaStore offers none of those,
* so the app holds MANAGE_EXTERNAL_STORAGE — which is granted on a
* Settings screen rather than in a dialog, and therefore cannot be
* requested with requestPermissions().
*
* <p>The screen is opened on every cold start until access exists,
* because without it the app can see nothing at all and there is no
* degraded mode worth offering. Returning from it lands in
* onResume, which re-checks and tells the frontend.
*
* <p>Below Android 11 there is no all-files concept and plain
* READ_EXTERNAL_STORAGE is both sufficient and a normal dialog.
*/
private void ensureStorageAccess() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (Environment.isExternalStorageManager()) {
return;
}
// The per-app screen is the one that can actually grant it.
// A few OEM builds do not implement it, so fall back to the
// global list rather than leaving the user with nothing.
try {
startActivity(new Intent(
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
Uri.parse("package:" + getPackageName())));
} catch (Exception e) {
Log.w(TAG, "per-app all-files screen unavailable: " + e.getMessage());
try {
startActivity(new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION));
} catch (Exception e2) {
Log.w(TAG, "no all-files settings screen at all: " + e2.getMessage());
}
}
return;
}
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
requestPermissions(new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, 1010);
}
}
/**
* Whether the app can currently read the user's music, by the same
* test the Go side uses.
*/
private boolean hasStorageAccess() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
return Environment.isExternalStorageManager();
}
return checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
== PackageManager.PERMISSION_GRANTED;
}
/**
* Tell the frontend whether music is readable. Emitted on resume
* rather than only at startup, because the grant happens on a
* Settings screen in another task and the way back is a resume.
*/
private void emitStorageAccess() {
if (bridge == null) {
return;
}
bridge.emitEvent("android:storageAccess",
"{\"granted\":" + (hasStorageAccess() ? "true" : "false") + "}");
}
@SuppressLint("SetJavaScriptEnabled")
private void setupWebView() {
webView = findViewById(R.id.webview);
bridge.setWebView(webView);
// Configure WebView settings
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setDatabaseEnabled(true);
settings.setAllowFileAccess(false);
settings.setAllowContentAccess(false);
settings.setMediaPlaybackRequiresUserGesture(false);
settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW);
// Enable debugging in debug builds
if (DEBUG) {
WebView.setWebContentsDebuggingEnabled(true);
}
// Set up asset loader for serving local assets
assetLoader = new WebViewAssetLoader.Builder()
.setDomain(WAILS_HOST)
.addPathHandler("/", new WailsPathHandler(bridge))
.build();
// Set up WebView client to intercept requests
webView.setWebViewClient(new WebViewClient() {
@Nullable
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
// Handle wails.localhost requests
if (request.getUrl().getHost() != null &&
request.getUrl().getHost().equals(WAILS_HOST)) {
// For wails API calls (runtime, capabilities, etc.) pass the
// full URL including the query string, because
// WebViewAssetLoader.PathHandler strips query params
String path = request.getUrl().getPath();
if (path != null && path.startsWith("/wails/")) {
String fullPath = path;
String query = request.getUrl().getQuery();
if (query != null && !query.isEmpty()) {
fullPath = path + "?" + query;
}
if (DEBUG) Log.d(TAG, "Wails API call: " + fullPath);
byte[] data = bridge.serveAsset(fullPath, request.getMethod(), "{}");
if (data != null && data.length > 0) {
java.io.InputStream inputStream = new java.io.ByteArrayInputStream(data);
java.util.Map<String, String> headers = new java.util.HashMap<>();
headers.put("Access-Control-Allow-Origin", "*");
headers.put("Cache-Control", "no-cache");
headers.put("Content-Type", "application/json");
return new WebResourceResponse(
"application/json",
"UTF-8",
200,
"OK",
headers,
inputStream
);
}
// Return error response if data is null
return new WebResourceResponse(
"application/json",
"UTF-8",
500,
"Internal Error",
new java.util.HashMap<>(),
new java.io.ByteArrayInputStream("{}".getBytes())
);
}
// Stream captured photos/videos from the cache with HTTP Range
// support so <video> can seek/stream a clip of any length.
if (path != null && path.startsWith("/__capture__/")) {
return serveCaptureFile(path.substring("/__capture__/".length()), request);
}
// For regular assets, use the asset loader
return assetLoader.shouldInterceptRequest(request.getUrl());
}
return super.shouldInterceptRequest(view, request);
}
@Override
public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
if (DEBUG) Log.d(TAG, "Page loaded: " + url);
bridge.onPageFinished(url);
// Now that JS listeners are mounted, push a snapshot of the
// current battery / network / theme so the UI starts populated.
emitSystemSnapshot();
}
});
// Add JavaScript interface for Go communication
webView.addJavascriptInterface(new WailsJSBridge(bridge, webView), "wails");
}
private void loadApplication() {
String url = WAILS_SCHEME + "://" + WAILS_HOST + "/";
if (DEBUG) Log.d(TAG, "Loading URL: " + url);
webView.loadUrl(url);
}
/**
* Launch the system camera to capture a photo (video=false) or a video
* (video=true). The capture is written to a FileProvider URI in the cache and
* the result is delivered to JS as a "common:capture" event.
*/
public void launchCameraCapture(boolean video) {
if (checkSelfPermission("android.permission.CAMERA") != PackageManager.PERMISSION_GRANTED) {
pendingCaptureIsVideo = video;
requestPermissions(new String[]{"android.permission.CAMERA"}, CAMERA_PERMISSION_REQUEST);
return;
}
try {
File dir = new File(getCacheDir(), "captures");
if (!dir.exists()) dir.mkdirs();
pendingCaptureFile = new File(dir, "capture_" + System.currentTimeMillis() + (video ? ".mp4" : ".jpg"));
pendingCaptureIsVideo = video;
Uri uri = FileProvider.getUriForFile(this, getPackageName() + ".fileprovider", pendingCaptureFile);
Intent intent = new Intent(video ? MediaStore.ACTION_VIDEO_CAPTURE : MediaStore.ACTION_IMAGE_CAPTURE);
intent.putExtra(MediaStore.EXTRA_OUTPUT, uri);
intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
// Don't pre-check with resolveActivity(): Android 11+ package visibility
// hides other apps' intents unless declared in <queries>, so it can
// return null even when a camera app exists. Just launch and handle a miss.
startActivityForResult(intent, video ? VIDEO_CAPTURE_REQUEST : PHOTO_CAPTURE_REQUEST);
} catch (android.content.ActivityNotFoundException e) {
bridge.emitEvent("common:capture", "{\"error\":\"no camera app available\"}");
} catch (Exception e) {
Log.e(TAG, "launchCameraCapture failed", e);
bridge.emitEvent("common:capture", "{\"error\":\"capture failed\"}");
}
}
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == CAMERA_PERMISSION_REQUEST) {
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
launchCameraCapture(pendingCaptureIsVideo);
} else {
bridge.emitEvent("common:capture", "{\"error\":\"camera permission denied\"}");
}
return;
}
if (bridge != null) {
bridge.onRequestPermissionsResult(requestCode, grantResults);
}
}
private void handleCaptureResult(int resultCode, @Nullable Intent data) {
File file = pendingCaptureFile;
final boolean video = pendingCaptureIsVideo;
pendingCaptureFile = null;
if (resultCode != RESULT_OK) {
bridge.emitEvent("common:capture", "{\"cancelled\":true}");
return;
}
// Some camera apps (commonly for video) ignore EXTRA_OUTPUT and instead
// return a content URI in the result data; copy that into our cache.
if ((file == null || !file.exists() || file.length() == 0)
&& data != null && data.getData() != null) {
String copied = copyUriToCache(data.getData());
if (copied != null) file = new File(copied);
}
final File f = file;
if (f == null || !f.exists() || f.length() == 0) {
bridge.emitEvent("common:capture", "{\"cancelled\":true}");
return;
}
new Thread(() -> {
try {
JSONObject o = new JSONObject();
o.put("type", video ? "video" : "photo");
o.put("path", f.getAbsolutePath());
o.put("size", f.length());
if (!video) {
String thumb = makePhotoThumbnail(f);
if (thumb != null) o.put("thumb", thumb);
}
// Stream URL works for both: <video>/<img> load it from the cache
// via shouldInterceptRequest (Range-enabled), no size limit.
o.put("streamUrl", captureStreamUrl(f));
bridge.emitEvent("common:capture", o.toString());
} catch (Exception e) {
Log.e(TAG, "handleCaptureResult failed", e);
bridge.emitEvent("common:capture", "{\"error\":\"result processing failed\"}");
}
}).start();
}
/** Downscale a captured photo into a base64 JPEG data URL for display in the webview. */
@Nullable
private String makePhotoThumbnail(File file) {
try {
BitmapFactory.Options bounds = new BitmapFactory.Options();
bounds.inJustDecodeBounds = true;
BitmapFactory.decodeFile(file.getAbsolutePath(), bounds);
int sample = 1;
while (Math.max(bounds.outWidth, bounds.outHeight) / sample > 640) sample *= 2;
BitmapFactory.Options opts = new BitmapFactory.Options();
opts.inSampleSize = sample;
Bitmap bmp = BitmapFactory.decodeFile(file.getAbsolutePath(), opts);
if (bmp == null) return null;
ByteArrayOutputStream baos = new ByteArrayOutputStream();
bmp.compress(Bitmap.CompressFormat.JPEG, 70, baos);
bmp.recycle();
return "data:image/jpeg;base64," + Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP);
} catch (Exception e) {
return null;
}
}
/**
* Build a same-origin URL the webview can stream a capture from. Served by
* serveCaptureFile (via shouldInterceptRequest); the path is relative to the
* cache dir so both camera files (captures/) and copied content URIs
* (wails-picker/) resolve.
*/
private String captureStreamUrl(File file) {
String base = getCacheDir().getAbsolutePath() + File.separator;
String abs = file.getAbsolutePath();
String rel = abs.startsWith(base) ? abs.substring(base.length()) : file.getName();
return "/__capture__/" + Uri.encode(rel, "/");
}
/**
* Serve a captured file (under the app cache) to the webview with HTTP Range
* support, so &lt;video&gt; can stream and seek a clip of any length without
* inlining it as a data URL.
*/
private WebResourceResponse serveCaptureFile(String relPath, WebResourceRequest request) {
try {
File cache = getCacheDir();
File file = new File(cache, Uri.decode(relPath));
// Path-traversal guard: only ever serve files under the cache dir.
if (!file.getCanonicalPath().startsWith(cache.getCanonicalPath() + File.separator)
|| !file.exists() || !file.isFile()) {
return new WebResourceResponse("text/plain", "UTF-8", 404, "Not Found",
new java.util.HashMap<>(), new java.io.ByteArrayInputStream(new byte[0]));
}
String name = file.getName().toLowerCase();
String mime = name.endsWith(".mp4") ? "video/mp4"
: name.endsWith(".mov") ? "video/quicktime"
: name.endsWith(".jpg") || name.endsWith(".jpeg") ? "image/jpeg"
: name.endsWith(".png") ? "image/png" : "application/octet-stream";
long length = file.length();
java.util.Map<String, String> reqHeaders = request.getRequestHeaders();
String range = reqHeaders != null ? reqHeaders.get("Range") : null;
if (range == null && reqHeaders != null) range = reqHeaders.get("range");
java.util.Map<String, String> headers = new java.util.HashMap<>();
headers.put("Accept-Ranges", "bytes");
headers.put("Cache-Control", "no-store");
if (range != null && range.startsWith("bytes=")) {
long start = 0, end = length - 1;
String spec = range.substring(6).trim();
int dash = spec.indexOf('-');
if (dash >= 0) {
try {
if (dash > 0) start = Long.parseLong(spec.substring(0, dash).trim());
String e = spec.substring(dash + 1).trim();
if (!e.isEmpty()) end = Long.parseLong(e);
} catch (NumberFormatException ignored) { }
}
if (start < 0) start = 0;
if (end >= length) end = length - 1;
if (start > end) { start = 0; end = length - 1; }
long count = end - start + 1;
java.io.InputStream in = new java.io.FileInputStream(file);
long toSkip = start;
while (toSkip > 0) {
long s = in.skip(toSkip);
if (s <= 0) break;
toSkip -= s;
}
headers.put("Content-Range", "bytes " + start + "-" + end + "/" + length);
headers.put("Content-Length", String.valueOf(count));
return new WebResourceResponse(mime, null, 206, "Partial Content",
headers, new LimitedInputStream(in, count));
}
headers.put("Content-Length", String.valueOf(length));
return new WebResourceResponse(mime, null, 200, "OK", headers,
new java.io.FileInputStream(file));
} catch (Exception e) {
Log.e(TAG, "serveCaptureFile failed", e);
return new WebResourceResponse("text/plain", "UTF-8", 500, "Error",
new java.util.HashMap<>(), new java.io.ByteArrayInputStream(new byte[0]));
}
}
/** Wraps a stream to yield at most a fixed number of bytes (for Range responses). */
private static final class LimitedInputStream extends java.io.FilterInputStream {
private long remaining;
LimitedInputStream(java.io.InputStream in, long limit) {
super(in);
this.remaining = limit;
}
@Override public int read() throws java.io.IOException {
if (remaining <= 0) return -1;
int b = super.read();
if (b >= 0) remaining--;
return b;
}
@Override public int read(byte[] b, int off, int len) throws java.io.IOException {
if (remaining <= 0) return -1;
int n = super.read(b, off, (int) Math.min(len, remaining));
if (n > 0) remaining -= n;
return n;
}
}
/**
* Launch the system document picker. Results are copied into the app's
* cache directory so Go receives real filesystem paths. Called by
* WailsBridge on the main thread.
*/
public void launchFilePicker(int callbackID, boolean multiple) {
synchronized (this) {
if (pendingFilePickerCallbackID != -1) {
// Only one picker can be in flight
bridge.filePickerDone(callbackID);
return;
}
pendingFilePickerCallbackID = callbackID;
}
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multiple);
try {
startActivityForResult(intent, FILE_PICKER_REQUEST);
} catch (Exception e) {
Log.e(TAG, "Failed to launch file picker", e);
pendingFilePickerCallbackID = -1;
bridge.filePickerDone(callbackID);
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == PHOTO_CAPTURE_REQUEST || requestCode == VIDEO_CAPTURE_REQUEST) {
handleCaptureResult(resultCode, data);
return;
}
if (requestCode != FILE_PICKER_REQUEST) {
return;
}
final int callbackID = pendingFilePickerCallbackID;
pendingFilePickerCallbackID = -1;
if (callbackID == -1) {
return;
}
final List<Uri> uris = new ArrayList<>();
if (resultCode == RESULT_OK && data != null) {
if (data.getClipData() != null) {
for (int i = 0; i < data.getClipData().getItemCount(); i++) {
uris.add(data.getClipData().getItemAt(i).getUri());
}
} else if (data.getData() != null) {
uris.add(data.getData());
}
}
// Copy the documents off the main thread, then notify Go
new Thread(() -> {
for (Uri uri : uris) {
String path = copyUriToCache(uri);
if (path != null) {
bridge.filePickerResult(callbackID, path);
}
}
bridge.filePickerDone(callbackID);
}).start();
}
/**
* Copy a content URI into the app cache and return its filesystem path.
*/
@Nullable
private String copyUriToCache(Uri uri) {
String name = "document";
try (Cursor cursor = getContentResolver().query(uri, null, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
int idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
if (idx >= 0 && cursor.getString(idx) != null) {
name = new File(cursor.getString(idx)).getName();
}
}
} catch (Exception ignored) {
}
try {
File dir = new File(getCacheDir(), "wails-picker/" + System.nanoTime());
if (!dir.mkdirs()) {
return null;
}
File out = new File(dir, name);
try (InputStream in = getContentResolver().openInputStream(uri);
OutputStream os = new FileOutputStream(out)) {
if (in == null) {
return null;
}
byte[] buf = new byte[64 * 1024];
int n;
while ((n = in.read(buf)) > 0) {
os.write(buf, 0, n);
}
}
return out.getAbsolutePath();
} catch (Exception e) {
Log.e(TAG, "Failed to copy picked document", e);
return null;
}
}
/**
* Execute JavaScript in the WebView from the Go side
*/
public void executeJavaScript(final String js) {
runOnUiThread(() -> {
if (webView != null) {
webView.evaluateJavascript(js, null);
}
});
}
// ---- System events ---------------------------------------------------
// Battery/power, screen lock and network connectivity are surfaced to JS as
// "system:*" events. The OS broadcasts used here (ACTION_BATTERY_CHANGED,
// SCREEN_OFF, USER_PRESENT, POWER_SAVE_MODE_CHANGED) are protected system
// broadcasts, so dynamic registration needs no RECEIVER_* export flag.
private void registerSystemEventReceivers() {
// Battery + charging state (sticky broadcast: the current value is
// delivered to the receiver immediately on registration).
batteryReceiver = new BroadcastReceiver() {
@Override public void onReceive(Context context, Intent intent) {
emitBattery(intent);
}
};
registerReceiver(batteryReceiver, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
// Low-power (battery saver) mode toggles → re-emit battery with the flag.
powerSaveReceiver = new BroadcastReceiver() {
@Override public void onReceive(Context context, Intent intent) {
emitBattery(registerSticky(Intent.ACTION_BATTERY_CHANGED));
}
};
registerReceiver(powerSaveReceiver,
new IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED));
// Screen lock / unlock. SCREEN_OFF ≈ locked; USER_PRESENT = unlocked.
screenReceiver = new BroadcastReceiver() {
@Override public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
if (Intent.ACTION_SCREEN_OFF.equals(action)) {
emitLock(true);
} else if (Intent.ACTION_USER_PRESENT.equals(action)) {
emitLock(false);
}
}
};
IntentFilter screenFilter = new IntentFilter();
screenFilter.addAction(Intent.ACTION_SCREEN_OFF);
screenFilter.addAction(Intent.ACTION_USER_PRESENT);
registerReceiver(screenReceiver, screenFilter);
// Network connectivity / transport type / cellular signal strength.
connectivityManager = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
if (connectivityManager != null) {
networkCallback = new ConnectivityManager.NetworkCallback() {
@Override public void onAvailable(Network network) { emitNetwork(network); }
@Override public void onLost(Network network) { emitNetworkDisconnected(); }
@Override public void onCapabilitiesChanged(Network network, NetworkCapabilities caps) {
emitNetwork(network);
}
};
try {
connectivityManager.registerDefaultNetworkCallback(networkCallback);
} catch (Exception e) {
Log.e(TAG, "registerDefaultNetworkCallback failed", e);
}
}
}
private void unregisterSystemEventReceivers() {
safeUnregister(batteryReceiver);
batteryReceiver = null;
safeUnregister(powerSaveReceiver);
powerSaveReceiver = null;
safeUnregister(screenReceiver);
screenReceiver = null;
if (connectivityManager != null && networkCallback != null) {
try {
connectivityManager.unregisterNetworkCallback(networkCallback);
} catch (Exception ignored) {
}
networkCallback = null;
}
}
private void safeUnregister(BroadcastReceiver r) {
if (r != null) {
try {
unregisterReceiver(r);
} catch (Exception ignored) {
}
}
}
/** Read the current sticky value for an action without a standing receiver. */
@Nullable
private Intent registerSticky(String action) {
return registerReceiver(null, new IntentFilter(action));
}
/** Push current battery / network / theme so a freshly-loaded UI is populated. */
private void emitSystemSnapshot() {
emitBattery(registerSticky(Intent.ACTION_BATTERY_CHANGED));
if (connectivityManager != null) {
Network active = connectivityManager.getActiveNetwork();
if (active != null) {
emitNetwork(active);
} else {
emitNetworkDisconnected();
}
}
emitTheme();
}
private void emitBattery(@Nullable Intent batteryStatus) {
try {
float level = -1f;
String state = "unknown";
if (batteryStatus != null) {
int lvl = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
int scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1);
if (lvl >= 0 && scale > 0) {
level = lvl / (float) scale;
}
switch (batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1)) {
case BatteryManager.BATTERY_STATUS_CHARGING: state = "charging"; break;
case BatteryManager.BATTERY_STATUS_FULL: state = "full"; break;
case BatteryManager.BATTERY_STATUS_DISCHARGING:
case BatteryManager.BATTERY_STATUS_NOT_CHARGING: state = "unplugged"; break;
default: state = "unknown"; break;
}
}
boolean lowPower = false;
PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
if (pm != null) {
lowPower = pm.isPowerSaveMode();
}
JSONObject o = new JSONObject();
o.put("level", (double) level);
o.put("state", state);
o.put("lowPowerMode", lowPower);
if (bridge != null) bridge.emitSystemEvent("android:BatteryChanged", o.toString());
} catch (Exception e) {
Log.e(TAG, "emitBattery failed", e);
}
}
private void emitNetwork(@Nullable Network network) {
try {
boolean connected = false;
String type = "none";
boolean metered = false;
Integer signal = null;
if (connectivityManager != null && network != null) {
NetworkCapabilities caps = connectivityManager.getNetworkCapabilities(network);
if (caps != null) {
connected = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET);
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
type = "wifi";
} else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
type = "cellular";
} else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
type = "wired";
} else {
type = "other";
}
metered = !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
int s = caps.getSignalStrength();
if (s != Integer.MIN_VALUE) {
signal = s; // dBm; closer to 0 is a stronger signal
}
}
}
}
JSONObject o = new JSONObject();
o.put("connected", connected);
o.put("type", type);
o.put("metered", metered);
if (signal != null) {
o.put("signal", (int) signal);
}
if (bridge != null) bridge.emitSystemEvent("android:NetworkChanged", o.toString());
} catch (Exception e) {
Log.e(TAG, "emitNetwork failed", e);
}
}
private void emitNetworkDisconnected() {
try {
JSONObject o = new JSONObject();
o.put("connected", false);
o.put("type", "none");
o.put("metered", false);
if (bridge != null) bridge.emitSystemEvent("android:NetworkChanged", o.toString());
} catch (Exception ignored) {
}
}
private void emitLock(boolean locked) {
// Lock/unlock are signals (no payload); name carries the state.
if (bridge != null) {
bridge.emitSystemEvent(locked ? "android:ScreenLocked" : "android:ScreenUnlocked", "{}");
}
}
private void emitTheme() {
try {
int mode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
JSONObject o = new JSONObject();
// "isDarkMode" matches the context key the desktop platforms use.
o.put("isDarkMode", mode == Configuration.UI_MODE_NIGHT_YES);
if (bridge != null) bridge.emitSystemEvent("android:ThemeChanged", o.toString());
} catch (Exception ignored) {
}
}
@Override
public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
// Fires for light/dark switches because the manifest lists uiMode in
// android:configChanges (otherwise the activity would be recreated).
emitTheme();
}
@Override
protected void onStart() {
super.onStart();
// Battery: only monitor system events while the app is visible.
if (!systemReceiversRegistered) {
registerSystemEventReceivers();
systemReceiversRegistered = true;
}
if (bridge != null) {
bridge.onStart();
}
}
@Override
protected void onResume() {
super.onResume();
if (bridge != null) {
bridge.onResume();
}
// The all-files grant happens on a Settings screen in another
// task, so a resume is how the app finds out it was given.
emitStorageAccess();
}
@Override
protected void onPause() {
super.onPause();
if (bridge != null) {
bridge.onPause();
}
}
@Override
protected void onStop() {
super.onStop();
if (systemReceiversRegistered) {
unregisterSystemEventReceivers();
systemReceiversRegistered = false;
}
if (bridge != null) {
bridge.onStop();
}
}
@Override
public void onLowMemory() {
super.onLowMemory();
if (bridge != null) {
bridge.onLowMemory();
}
}
@Override
protected void onDestroy() {
super.onDestroy();
unregisterSystemEventReceivers();
if (bridge != null) {
bridge.shutdown();
}
if (webView != null) {
webView.destroy();
}
}
/**
* Keep the web content inside the safe area.
*
* <p>targetSdk 35 is Android 15, which lays every app out
* edge-to-edge and ignores the {@code statusBarColor} and
* {@code navigationBarColor} this app's theme still sets. The
* WebView is {@code match_parent}, so the page's bottom band -- the
* transport and, on a phone, the tab bar -- was drawn underneath the
* gesture bar and reported from a device as "I can't see the
* playback controls, they seem to be off screen".
*
* <p>No web-tier test can see this: a browser viewport has no system
* bars, so the phone specs at 390x844 render a shell that fits
* while the device does not.
*
* <p>The insets are applied as padding and the window insets are
* returned rather than consumed, so the WebView is laid out inside
* them. {@code ime()} is in the mask because the same reasoning
* covers the keyboard: a focused search box that the keyboard
* covers is the same bug one surface over.
*/
private void applyWindowInsets() {
final View container = findViewById(R.id.main_container);
if (container == null) {
return;
}
ViewCompat.setOnApplyWindowInsetsListener(container, (view, windowInsets) -> {
Insets insets = windowInsets.getInsets(
WindowInsetsCompat.Type.systemBars()
| WindowInsetsCompat.Type.displayCutout()
| WindowInsetsCompat.Type.ime());
view.setPadding(insets.left, insets.top, insets.right, insets.bottom);
return windowInsets;
});
// The padded band shows the window background, which is dark
// (this app's own default ramp is black), so the system's icons
// have to be the light set or they vanish into it. The theme is
// DayNight and would otherwise ask for dark icons in light mode.
WindowInsetsControllerCompat controller =
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
controller.setAppearanceLightStatusBars(false);
controller.setAppearanceLightNavigationBars(false);
}
@Override
public void onBackPressed() {
// The frontend records every navigation as a history entry, so
// this is the app's own back stack: `canGoBack()` is false only
// at the launch entry, which is where back should leave.
if (webView != null && webView.canGoBack()) {
webView.goBack();
} else {
super.onBackPressed();
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,553 @@
package com.wails.app;
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 org.json.JSONObject;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* 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";
// 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 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);
}
// 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;
}
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;
}
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) {
startForeground(NOTIFICATION_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
} else {
startForeground(NOTIFICATION_ID, n);
}
}
@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
@Override
public IBinder onBind(Intent intent) {
return null;
}
}
@@ -0,0 +1,151 @@
package com.wails.app;
import android.util.Log;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import android.webkit.JavascriptInterface;
import android.webkit.WebView;
import com.wails.app.BuildConfig;
/**
* WailsJSBridge provides the JavaScript interface that allows the web frontend
* to communicate with the Go backend. This is exposed to JavaScript as the
* `window.wails` object.
*
* Similar to iOS's WKScriptMessageHandler but using Android's addJavascriptInterface.
*/
public class WailsJSBridge {
private static final String TAG = "WailsJSBridge";
private static final boolean DEBUG = BuildConfig.DEBUG;
// Pooled threads avoid unbounded thread creation under high call volume.
private static final ExecutorService executor = Executors.newCachedThreadPool();
private final WailsBridge bridge;
private final WebView webView;
public WailsJSBridge(WailsBridge bridge, WebView webView) {
this.bridge = bridge;
this.webView = webView;
}
/**
* Send a message to Go and return the response synchronously.
* Called from JavaScript: wails.invoke(message)
*
* @param message The message to send (JSON string)
* @return The response from Go (JSON string)
*/
@JavascriptInterface
public String invoke(String message) {
if (DEBUG) Log.d(TAG, "Invoke called: " + message);
return bridge.handleMessage(message);
}
/**
* Send a message to Go asynchronously.
* The response will be sent back via a callback.
* Called from JavaScript: wails.invokeAsync(callbackId, message)
*
* @param callbackId The callback ID to use for the response
* @param message The message to send (JSON string)
*/
@JavascriptInterface
public void invokeAsync(final String callbackId, final String payload) {
if (DEBUG) Log.d(TAG, "InvokeAsync called: " + payload);
// Handle off the JS thread so we don't block the WebView.
executor.execute(() -> {
try {
String response = bridge.handleRuntimeCall(payload);
sendCallback(callbackId, response, null);
} catch (Exception e) {
Log.e(TAG, "Error in async invoke", e);
sendCallback(callbackId, null, e.getMessage());
}
});
}
/**
* Log a message from JavaScript to Android's logcat
* Called from JavaScript: wails.log(level, message)
*
* @param level The log level (debug, info, warn, error)
* @param message The message to log
*/
@JavascriptInterface
public void log(String level, String message) {
switch (level.toLowerCase()) {
case "debug":
Log.d(TAG + "/JS", message);
break;
case "info":
Log.i(TAG + "/JS", message);
break;
case "warn":
Log.w(TAG + "/JS", message);
break;
case "error":
Log.e(TAG + "/JS", message);
break;
default:
Log.v(TAG + "/JS", message);
break;
}
}
/**
* Get the platform name
* Called from JavaScript: wails.platform()
*
* @return "android"
*/
@JavascriptInterface
public String platform() {
return "android";
}
/**
* Check if we're running in debug mode
* Called from JavaScript: wails.isDebug()
*
* @return true if debug build, false otherwise
*/
@JavascriptInterface
public boolean isDebug() {
return BuildConfig.DEBUG;
}
/**
* Send a callback response to JavaScript
*/
private void sendCallback(String callbackId, String result, String error) {
final String js;
if (error != null) {
js = String.format(
"window._wailsAndroidCallback && window._wailsAndroidCallback('%s', null, '%s');",
escapeJsString(callbackId),
escapeJsString(error)
);
} else {
js = String.format(
"window._wailsAndroidCallback && window._wailsAndroidCallback('%s', '%s', null);",
escapeJsString(callbackId),
escapeJsString(result != null ? result : "")
);
}
webView.post(() -> webView.evaluateJavascript(js, null));
}
private String escapeJsString(String str) {
if (str == null) return "";
return str.replace("\\", "\\\\")
.replace("'", "\\'")
.replace("\n", "\\n")
.replace("\r", "\\r")
// JS line terminators (U+2028/U+2029) must be escaped too; built via
// (char) casts so the Java lexer does not reinterpret them as newlines.
.replace(String.valueOf((char) 0x2028), "\\u2028")
.replace(String.valueOf((char) 0x2029), "\\u2029");
}
}
@@ -0,0 +1,68 @@
package com.wails.app;
import android.net.Uri;
import android.util.Log;
import android.webkit.WebResourceResponse;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.webkit.WebViewAssetLoader;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
/**
* WailsPathHandler implements WebViewAssetLoader.PathHandler to serve assets
* from the Go asset server. This allows the WebView to load assets without
* using a network server, similar to iOS's WKURLSchemeHandler.
*/
public class WailsPathHandler implements WebViewAssetLoader.PathHandler {
private static final String TAG = "WailsPathHandler";
private static final boolean DEBUG = BuildConfig.DEBUG;
private final WailsBridge bridge;
public WailsPathHandler(WailsBridge bridge) {
this.bridge = bridge;
}
@Nullable
@Override
public WebResourceResponse handle(@NonNull String path) {
if (DEBUG) Log.d(TAG, "Handling path: " + path);
// Normalize path
if (path.isEmpty() || path.equals("/")) {
path = "/index.html";
}
// Get asset from Go
byte[] data = bridge.serveAsset(path, "GET", "{}");
if (data == null || data.length == 0) {
Log.w(TAG, "Asset not found: " + path);
return null; // Return null to let WebView handle 404
}
// Determine MIME type
String mimeType = bridge.getAssetMimeType(path);
if (DEBUG) Log.d(TAG, "Serving " + path + " with type " + mimeType + " (" + data.length + " bytes)");
// Create response
InputStream inputStream = new ByteArrayInputStream(data);
Map<String, String> headers = new HashMap<>();
headers.put("Access-Control-Allow-Origin", "*");
headers.put("Cache-Control", "no-cache");
return new WebResourceResponse(
mimeType,
"UTF-8",
200,
"OK",
headers,
inputStream
);
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/main_container"
android:layout_width="match_parent"
android:layout_height="match_parent">
<WebView
android:id="@+id/webview"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="wails_blue">#3574D4</color>
<color name="wails_blue_dark">#2C5FB8</color>
<!-- The window background, which is what the launch screen shows and
what the system-bar padding leaves visible. Black rather than the
scaffold's blue-grey because this app's own default ramp is
black: a band of #1B2636 above and below it reads as the app
failing to fill the screen. -->
<color name="wails_background">#000000</color>
<color name="white">#FFFFFFFF</color>
<color name="black">#FF000000</color>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">YellowJacket</string>
</resources>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.WailsApp" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/wails_blue</item>
<item name="colorPrimaryVariant">@color/wails_blue_dark</item>
<item name="colorOnPrimary">@android:color/white</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">@color/wails_background</item>
<item name="android:navigationBarColor">@color/wails_background</item>
<!-- Window background -->
<item name="android:windowBackground">@color/wails_background</item>
</style>
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- Camera captures are written here and shared with the camera app via the
FileProvider. -->
<cache-path name="captures" path="captures/" />
</paths>
+4
View File
@@ -0,0 +1,4 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '8.7.3' apply false
}
+26
View File
@@ -0,0 +1,26 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/build/optimize-your-build#parallel
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
Binary file not shown.
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+93
View File
@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+11
View File
@@ -0,0 +1,11 @@
//go:build android
package main
import "github.com/wailsapp/wails/v3/pkg/application"
func init() {
// Register main function to be called when the Android app initializes
// This is necessary because in c-shared build mode, main() is not automatically called
application.RegisterAndroidMain(main)
}
+266
View File
@@ -0,0 +1,266 @@
package main
import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
)
func main() {
fmt.Println("Checking Android development dependencies...")
fmt.Println()
errors := []string{}
// Check Go
if !checkCommand("go", "version") {
errors = append(errors, "Go is not installed. Install from https://go.dev/dl/")
} else {
fmt.Println("✓ Go is installed")
}
// Check ANDROID_HOME
androidHome := os.Getenv("ANDROID_HOME")
if androidHome == "" {
androidHome = os.Getenv("ANDROID_SDK_ROOT")
}
if androidHome == "" {
// Try common default locations
home, _ := os.UserHomeDir()
possiblePaths := []string{
filepath.Join(home, "Android", "Sdk"),
filepath.Join(home, "Library", "Android", "sdk"),
"/usr/local/share/android-sdk",
}
for _, p := range possiblePaths {
if _, err := os.Stat(p); err == nil {
androidHome = p
break
}
}
}
if androidHome == "" {
errors = append(errors, "ANDROID_HOME not set. Install Android Studio and set ANDROID_HOME environment variable")
} else {
fmt.Printf("✓ ANDROID_HOME: %s\n", androidHome)
}
// Check adb
if !checkCommand("adb", "version") {
if androidHome != "" {
platformTools := filepath.Join(androidHome, "platform-tools")
errors = append(errors, fmt.Sprintf("adb not found. Add %s to PATH", platformTools))
} else {
errors = append(errors, "adb not found. Install Android SDK Platform-Tools")
}
} else {
fmt.Println("✓ adb is installed")
}
// Check emulator
if !checkCommand("emulator", "-list-avds") {
if androidHome != "" {
emulatorPath := filepath.Join(androidHome, "emulator")
errors = append(errors, fmt.Sprintf("emulator not found. Add %s to PATH", emulatorPath))
} else {
errors = append(errors, "emulator not found. Install Android Emulator via SDK Manager")
}
} else {
fmt.Println("✓ Android Emulator is installed")
}
// Check NDK
ndkHome := os.Getenv("ANDROID_NDK_HOME")
if ndkHome == "" && androidHome != "" {
// Look for NDK in default location
ndkDir := filepath.Join(androidHome, "ndk")
if entries, err := os.ReadDir(ndkDir); err == nil {
for _, entry := range entries {
if entry.IsDir() {
ndkHome = filepath.Join(ndkDir, entry.Name())
break
}
}
}
}
if ndkHome == "" {
errors = append(errors, "Android NDK not found. Install NDK via Android Studio > SDK Manager > SDK Tools > NDK (Side by side)")
} else {
fmt.Printf("✓ Android NDK: %s\n", ndkHome)
}
// Check Java
if !checkCommand("java", "-version") {
errors = append(errors, "Java not found. Install JDK 11+ (OpenJDK recommended)")
} else {
fmt.Println("✓ Java is installed")
}
// Check for AVD (Android Virtual Device)
if checkCommand("emulator", "-list-avds") {
cmd := exec.Command("emulator", "-list-avds")
output, err := cmd.Output()
if err == nil && len(strings.TrimSpace(string(output))) > 0 {
avds := strings.Split(strings.TrimSpace(string(output)), "\n")
fmt.Printf("✓ Found %d Android Virtual Device(s)\n", len(avds))
} else {
// Mirror the iOS installer, which offers to create a simulator when
// none exist. Only create from an already-installed system image.
offerCreateAVD(androidHome)
}
}
fmt.Println()
if len(errors) > 0 {
fmt.Println("❌ Missing dependencies:")
for _, err := range errors {
fmt.Printf(" - %s\n", err)
}
fmt.Println()
fmt.Println("Setup instructions:")
fmt.Println("1. Install Android Studio: https://developer.android.com/studio")
fmt.Println("2. Open SDK Manager and install:")
fmt.Println(" - Android SDK Platform (API 35)")
fmt.Println(" - Android SDK Build-Tools")
fmt.Println(" - Android SDK Platform-Tools")
fmt.Println(" - Android Emulator")
fmt.Println(" - NDK (Side by side)")
fmt.Println("3. Set environment variables:")
if runtime.GOOS == "darwin" {
fmt.Println(" export ANDROID_HOME=$HOME/Library/Android/sdk")
} else {
fmt.Println(" export ANDROID_HOME=$HOME/Android/Sdk")
}
fmt.Println(" export PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator")
fmt.Println("4. Create an AVD via Android Studio > Tools > Device Manager")
os.Exit(1)
}
fmt.Println("✓ All Android development dependencies are installed!")
}
func checkCommand(name string, args ...string) bool {
cmd := exec.Command(name, args...)
cmd.Stdout = nil
cmd.Stderr = nil
return cmd.Run() == nil
}
// offerCreateAVD mirrors the iOS installer's simulator offer: when no AVD
// exists, offer to create one — but ONLY from a system image that is already
// installed. We never run sdkmanager here (a multi-GB download with a license
// prompt is a surprise the user should trigger themselves).
func offerCreateAVD(androidHome string) {
abi := "x86_64"
if runtime.GOARCH == "arm64" {
abi = "arm64-v8a"
}
// Find the highest-API installed system image matching the host ABI.
// The API level must be compared numerically: lexicographic sorting
// would rank android-9 above android-35.
var img string
if androidHome != "" {
matches, _ := filepath.Glob(filepath.Join(androidHome, "system-images", "android-*", "*", abi))
bestAPI := -1
for _, m := range matches {
apiDir := filepath.Base(filepath.Dir(filepath.Dir(m)))
api, err := strconv.Atoi(strings.TrimPrefix(apiDir, "android-"))
if err != nil {
continue // preview/extension images (e.g. android-35-ext14)
}
if api > bestAPI {
bestAPI = api
img = m
}
}
}
avdmanager := findAVDManager(androidHome)
if img == "" || avdmanager == "" {
fmt.Println("⚠ No Android Virtual Devices found.")
fmt.Println(" Install a system image and create an AVD, e.g.:")
fmt.Printf(" sdkmanager 'system-images;android-35;google_apis;%s'\n", abi)
fmt.Printf(" avdmanager create avd --name wails --package 'system-images;android-35;google_apis;%s' --device pixel_7\n", abi)
return
}
// Derive the package path from the installed image directory, e.g.
// <sdk>/system-images/android-35/google_apis/arm64-v8a
// -> system-images;android-35;google_apis;arm64-v8a
rel := strings.TrimPrefix(img, filepath.Join(androidHome, "system-images")+string(os.PathSeparator))
pkg := "system-images;" + strings.ReplaceAll(rel, string(os.PathSeparator), ";")
fmt.Println("⚠ No Android Virtual Devices found.")
fmt.Printf(" Would you like to create a 'wails' AVD from %s?\n", pkg)
if !promptUser("Create AVD?") {
fmt.Println(" Skipping AVD creation.")
fmt.Printf(" Create manually: avdmanager create avd --name wails --package '%s' --device pixel_7\n", pkg)
return
}
cmd := exec.Command(avdmanager, "create", "avd", "--name", "wails", "--package", pkg, "--device", "pixel_7", "--force")
cmd.Stdin = strings.NewReader("no\n") // decline the custom hardware-profile prompt
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Printf(" Failed to create AVD: %v\n", err)
} else {
fmt.Println(" ✅ 'wails' AVD created")
}
}
// findAVDManager returns the avdmanager path from PATH, or from the SDK's
// cmdline-tools (preferring the newest version), or "" if not found.
func findAVDManager(androidHome string) string {
if p, err := exec.LookPath("avdmanager"); err == nil {
return p
}
if androidHome != "" {
matches, _ := filepath.Glob(filepath.Join(androidHome, "cmdline-tools", "*", "bin", "avdmanager"))
// Prefer the "latest" alias; otherwise compare versions numerically
// ("9.0" would lexicographically outrank "11.0").
best := ""
bestVersion := -1.0
for _, m := range matches {
version := filepath.Base(filepath.Dir(filepath.Dir(m)))
if version == "latest" {
return m
}
v, err := strconv.ParseFloat(version, 64)
if err != nil {
v = 0
}
if v > bestVersion {
bestVersion = v
best = m
}
}
return best
}
return ""
}
func promptUser(question string) bool {
if os.Getenv("CI") != "" || os.Getenv("TASK_FORCE_YES") == "true" {
fmt.Printf("%s [y/N]: y (auto-accepted)\n", question)
return true
}
reader := bufio.NewReader(os.Stdin)
fmt.Printf("%s [y/N]: ", question)
response, err := reader.ReadString('\n')
if err != nil {
return false
}
response = strings.ToLower(strings.TrimSpace(response))
return response == "y" || response == "yes"
}
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "WailsApp"
include ':app'
+191
View File
@@ -0,0 +1,191 @@
//go:build indexbuild
package main
import (
"database/sql"
"path/filepath"
"strings"
"testing"
_ "modernc.org/sqlite"
)
// The columns an index built before the completeness work has: every
// current one except total_tracks.
//
// Filtered rather than string-replaced, because the list is formatted
// across lines: `strings.Replace(catalogColumns, "total_tracks, ", …)`
// matches nothing (the name is followed by a newline, not a space) and
// silently yields the *current* list -- so the test built a modern
// source index and proved nothing while passing its own premise.
var oldColumns = withoutTotals(catalogColumns)
func withoutTotals(cols string) string {
kept := make([]string, 0, 20)
for _, part := range strings.Split(cols, ",") {
if strings.TrimSpace(part) == "total_tracks" {
continue
}
kept = append(kept, strings.TrimSpace(part))
}
return strings.Join(kept, ", ")
}
// TestExportFromAnIndexWithoutTotals reproduces the failure that broke
// the index-artifact job, symptom first.
//
// The job's /cache volume is a real YJ_HOME that survives between runs
// and holds ~205 GB, so its explore_index is Cache and is deliberately
// not dropped by cmd/indexbuild's schema repair -- which means a column
// added to the schema afterwards is simply absent from it. The exporter
// selected it anyway and the whole run died with
//
// indexexport: copy rows: SQL logic error: no such column: total_tracks
//
// after three minutes of work, on a job that publishes the catalog
// every user downloads.
func TestExportFromAnIndexWithoutTotals(t *testing.T) {
t.Parallel()
db := openWithSource(t, oldColumns)
if got := sourceColumns(db); strings.Contains(got, "total_tracks") {
t.Fatalf("source list still names total_tracks: %s", got)
}
if err := copyRows(db, 10, 5, 5); err != nil {
t.Fatalf("export from an index without total_tracks: %v", err)
}
// Zero, not absent: the artifact keeps every column so an importer
// needs no second shape, and 0 is what the column already means by
// "the catalog does not say".
var total int
if err := db.QueryRow(
`SELECT total_tracks FROM core.explore_index WHERE entity_type = 2`,
).Scan(&total); err != nil {
t.Fatalf("read exported total_tracks: %v", err)
}
if total != 0 {
t.Errorf("total_tracks = %d, want 0", total)
}
}
// TestExportCarriesTotalsWhenTheIndexHasThem is the other half: the
// probe must not cost the totals of an index that does have them.
func TestExportCarriesTotalsWhenTheIndexHasThem(t *testing.T) {
t.Parallel()
db := openWithSource(t, catalogColumns)
if got := sourceColumns(db); !strings.Contains(got, "total_tracks") {
t.Fatalf("source list dropped total_tracks: %s", got)
}
if err := copyRows(db, 10, 5, 5); err != nil {
t.Fatalf("export: %v", err)
}
var total int
if err := db.QueryRow(
`SELECT total_tracks FROM core.explore_index WHERE entity_type = 2`,
).Scan(&total); err != nil {
t.Fatalf("read exported total_tracks: %v", err)
}
if total != 12 {
t.Errorf("total_tracks = %d, want 12", total)
}
}
// openWithSource builds a source index carrying exactly `columns`, with
// one artist and one of its release groups, and attaches a fresh
// artifact database as `core`.
func openWithSource(t *testing.T, columns string) *sql.DB {
t.Helper()
dir := t.TempDir()
db, err := sql.Open("sqlite", filepath.Join(dir, "src.db"))
if err != nil {
t.Fatalf("open source: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
// The source's shape is the point of the test, so it is spelled
// out here rather than taken from the app's schema, which is
// always current by definition.
create := `CREATE TABLE explore_index (
id INTEGER PRIMARY KEY,
entity_type INTEGER NOT NULL,
mbid BLOB NOT NULL,
title TEXT NOT NULL DEFAULT '',
artist_name TEXT NOT NULL DEFAULT '',
artist_mbid BLOB NOT NULL DEFAULT x'',
aliases TEXT NOT NULL DEFAULT '',
popularity INTEGER NOT NULL DEFAULT 0,
listener_count INTEGER NOT NULL DEFAULT 0,
duration INTEGER NOT NULL DEFAULT 0,
caa_release_mbid BLOB NOT NULL DEFAULT x'',
release_name TEXT NOT NULL DEFAULT '',
primary_type TEXT NOT NULL DEFAULT '',
secondary_types TEXT NOT NULL DEFAULT '',
release_date TEXT NOT NULL DEFAULT '',
total_tracks INTEGER NOT NULL DEFAULT 0,
artist_type TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
disambiguation TEXT NOT NULL DEFAULT '',
sort_name TEXT NOT NULL DEFAULT '',
discog_fetched INTEGER NOT NULL DEFAULT 0
)`
if !strings.Contains(columns, "total_tracks") {
create = strings.Replace(
create, "total_tracks INTEGER NOT NULL DEFAULT 0,\n", "", 1,
)
}
if _, err := db.Exec(create); err != nil {
t.Fatalf("create source: %v", err)
}
seed := `INSERT INTO explore_index (` + columns + `) VALUES `
if strings.Contains(columns, "total_tracks") {
seed += `(1, x'00000000000000000000000000000001', 'A', 'A',
x'00000000000000000000000000000001', '', 100, 100, 0, x'',
'', '', '', '', 12, '', '', '', '', 0),
(2, x'00000000000000000000000000000002', 'RG', 'A',
x'00000000000000000000000000000001', '', 90, 90, 0, x'',
'', 'Album', '', '', 12, '', '', '', '', 0)`
} else {
seed += `(1, x'00000000000000000000000000000001', 'A', 'A',
x'00000000000000000000000000000001', '', 100, 100, 0, x'',
'', '', '', '', '', '', '', '', 0),
(2, x'00000000000000000000000000000002', 'RG', 'A',
x'00000000000000000000000000000001', '', 90, 90, 0, x'',
'', 'Album', '', '', '', '', '', '', 0)`
}
if _, err := db.Exec(seed); err != nil {
t.Fatalf("seed source: %v", err)
}
if _, err := db.Exec(
`ATTACH DATABASE ? AS core`, filepath.Join(dir, "core.db"),
); err != nil {
t.Fatalf("attach core: %v", err)
}
if err := createSchema(db); err != nil {
t.Fatalf("create artifact schema: %v", err)
}
return db
}
+42 -2
View File
@@ -25,6 +25,7 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"time"
_ "modernc.org/sqlite"
@@ -43,6 +44,41 @@ const catalogColumns = `entity_type, mbid, title, artist_name, artist_mbid,
release_name, primary_type, secondary_types, release_date, total_tracks,
artist_type, country, disambiguation, sort_name, discog_fetched`
// sourceColumns is catalogColumns as read *from* the built index,
// which is not always shaped like the one this binary was compiled
// against.
//
// The index job's /cache volume is a real YJ_HOME that survives
// between runs and holds ~205 GB nobody can re-download casually, so
// its explore_index is classified Cache and is deliberately **not**
// dropped and recreated by cmd/indexbuild's schema repair. A column
// added to the schema after that database was built is therefore
// absent from it, and selecting it fails the whole export with
// "no such column: total_tracks" -- which is what happened the first
// time the job ran after the completeness work.
//
// So the source list is asked for rather than assumed, exactly as
// artifactHasTotals does on the importing side. Zero is what the
// column means by "the catalog does not say", and the app already
// renders that as unknown rather than as incomplete.
func sourceColumns(db *sql.DB) string {
var n int
err := db.QueryRow(
`SELECT COUNT(*) FROM pragma_table_info('explore_index', 'main')
WHERE name = 'total_tracks'`,
).Scan(&n)
if err == nil && n > 0 {
return catalogColumns
}
fmt.Println(
" note: this index predates total_tracks; exporting 0 for it",
)
return strings.Replace(catalogColumns, "total_tracks", "0", 1)
}
var errNoHome = errors.New(
"YJ_HOME must be set to the directory holding the built index",
)
@@ -209,6 +245,10 @@ func createSchema(db *sql.DB) error {
// dumpcatalog.go — a flat global top-N would give a handful of
// superstars everything and everyone else nothing.
func copyRows(db *sql.DB, artists, perArtistRGs, perArtistRecs int) error {
// The destination is created by this binary and always has every
// column; only the source may be older.
srcColumns := sourceColumns(db)
if _, err := db.Exec(`
CREATE TEMP TABLE core_artists AS
SELECT mbid FROM main.explore_index
@@ -221,7 +261,7 @@ func copyRows(db *sql.DB, artists, perArtistRGs, perArtistRecs int) error {
copied, err := insertSelect(db, `
INSERT INTO core.explore_index (`+catalogColumns+`)
SELECT `+catalogColumns+`
SELECT `+srcColumns+`
FROM main.explore_index
WHERE entity_type = 1 /* artist */
AND mbid IN (SELECT mbid FROM core_artists)`)
@@ -248,7 +288,7 @@ func copyRows(db *sql.DB, artists, perArtistRGs, perArtistRecs int) error {
// most `limit` rows, ranked by their own listen counts.
n, err := insertSelect(db, `
INSERT INTO core.explore_index (`+catalogColumns+`)
SELECT `+catalogColumns+` FROM (
SELECT `+srcColumns+` FROM (
SELECT *, ROW_NUMBER() OVER (
PARTITION BY artist_mbid ORDER BY popularity DESC
) AS rn
+147
View File
@@ -0,0 +1,147 @@
# Releasing the Android APK
`.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.
```
https://git.ljones.me/api/packages/yonlu/generic/yellowjacket-android/latest/yellowjacket.apk
```
A versioned copy is kept alongside it at
`…/yellowjacket-android/<version>/yellowjacket-<version>.apk`.
The app on the device is **`app.yellowjacket`**. Its launcher activity is
`com.wails.app.MainActivity` — a different package, because that is the
Wails scaffold's Java package and renaming it would mean renaming its
source. Every `am start` needs the fully-qualified form.
## The signing key is the thing you cannot lose
**Android refuses to update an app whose signing certificate changed.**
There is no override and no recovery: the only way to install a build
signed with a different key is to uninstall first, which takes the
user's library, playlists and play counts with it. The key therefore
outlives every other secret in this repo.
Two consequences are wired into the workflow rather than left to
discipline. It **refuses to build** when `ANDROID_KEYSTORE_B64` is
absent, instead of falling through to Gradle's debug-keystore default —
a debug key differs between every machine and every CI runner, so a
build signed with one is un-updatable from the moment it is installed.
And it **refuses to publish** an APK whose certificate reads
`CN=Android Debug`, which is the same rule enforced one step later, on
the artifact rather than the configuration.
### Creating it
```bash
keytool -genkeypair -v \
-keystore yellowjacket-release.jks \
-alias yellowjacket \
-keyalg RSA -keysize 2048 -validity 10000 \
-storepass '<a long random password>' \
-dname "CN=YellowJacket, O=Shadow-Puppet, C=GB"
```
**Do not pass `-keypass`.** keytool has produced PKCS12 keystores by
default since JDK 9 — the `.jks` extension does not change the format —
and PKCS12 cannot hold a key password distinct from the store password.
Given one it tells you so and ignores it:
```
Warning: Different store and key passwords not supported for PKCS12
KeyStores. Ignoring user-specified -keypass value.
```
So there is **one** password. Asking for a second is how someone sets a
wrong value and then debugs Gradle at midnight.
Back the `.jks` up somewhere that is not this repository and not this
server. Record the certificate fingerprint the build prints
(`Signer #1 certificate SHA-256 digest`); if it ever changes, updates
have already broken.
### The secrets
Repository → Settings → Actions → Secrets.
| Secret | Required | Notes |
|---|---|---|
| `ANDROID_KEYSTORE_B64` | yes | `base64 -w0 yellowjacket-release.jks` |
| `ANDROID_KEYSTORE_PASSWORD` | yes | the `-storepass` above |
| `ANDROID_KEY_ALIAS` | no | defaults to `yellowjacket` |
| `ANDROID_KEY_PASSWORD` | no | defaults to the store password, and per the PKCS12 note it cannot differ |
| `PACKAGE_TOKEN` | already set | shared with `arch-package.yml`; publishes to the registry |
```bash
base64 -w0 yellowjacket-release.jks # paste as ANDROID_KEYSTORE_B64
```
## Cutting a release
```bash
git tag v1.3.1
git push origin v1.3.1
```
That is the whole trigger. `homebrew-formula.yml` keys on the same tag,
so the desktop formula and the APK are cut together. The version code
Android orders releases by is derived from the tag — `1.3.1``10301`,
monotonic as long as minor and patch stay below 100 — so **do not
publish `1.100.0`**, and never move a tag that has already been built.
`workflow_dispatch` rebuilds without a new tag, taking an explicit
`version` input or falling back to the latest `v*` tag.
## What the workflow checks before publishing
- the APK exists and is non-empty;
- 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.
The keystore is also opened with `keytool -list` before Gradle runs,
because Gradle only notices a bad password at
`:app:validateSigningRelease` — a minute of build time in — and reports
it as a missing file rather than a wrong password.
## Caches, and the first run
The job mounts four cache volumes; on a cold runner the first build is
slow and everything after it is not.
| Volume | Holds | Cold cost |
|---|---|---|
| `/cache/android-sdk` | SDK, platform, build-tools, NDK r26d | ~2 GB |
| `/cache/gradle` | `GRADLE_USER_HOME` — wrapper + AGP graph | ~700 MB |
| `/cache/tool` | the Go toolchain (shared with `ci.yml`) | ~200 MB |
| `/cache/pnpm-store` | pnpm store (shared with `ci.yml`) | — |
Every path must be inside the runner's `valid_volumes` allowlist. One
that is not makes the job **fail to start** rather than silently skip
the mount.
The NDK is pinned to **r26d** (`26.3.11579264`). Newer NDKs have broken
the Wails Android build before; it is a version, not a floor.
## Building one locally
```bash
make android # unsigned-ish: debug key, versionCode 1, 0.0.0
YJ_VERSION=1.3.1 YJ_VERSION_CODE=10301 \
ANDROID_KEYSTORE_FILE=$PWD/yellowjacket-release.jks \
ANDROID_KEYSTORE_PASSWORD=... ANDROID_KEY_ALIAS=yellowjacket \
make android # what CI produces
```
Running it is a separate tier — see
`.pi/skills/yellowjacket-dev/references/android-tier.md`, and read its
first section before you try, because a failing Android build looks
exactly like a working one.
+92
View File
@@ -0,0 +1,92 @@
import { test, expect } from '../support/fixtures.js';
/**
* Back is the platform's, and the app has to have somewhere for it to
* go (reported from a device: "the Android back button does not
* navigate back in the app").
*
* The scaffold's `MainActivity.onBackPressed` asks `webView.canGoBack()`
* and finishes the activity otherwise. This app never touched
* `history`, so that was always false and back quit from any depth. A
* navigation is a history entry now, which is why this is assertable
* here at all: `page.goBack()` is the same `popstate` the phone's
* gesture produces, so the browser tier can answer a question that
* otherwise needs a device.
*
* What it cannot answer is whether Android's *gesture* reaches the
* WebView, which is between the OS and the scaffold.
*/
type Page = import('@playwright/test').Page;
const activeView = (page: Page) =>
page.getByTestId('main-content');
/**
* Open an artist's detail view, which is the deepest ordinary route.
*
* A library artist opens `explore-artist-details` -- the catalog panel
* standing in for a library one, as `explore-link.ts` describes -- and
* the view name follows the component, not the source of the click.
*/
async function openAnArtist(app: Page): Promise<void> {
await app.getByTestId('nav-artists').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'artists');
// A card, by the name on it: the grid is virtualized and positioned
// by transform, so a click at coordinates is a click at whatever
// happens to be there.
await app.locator('artists-view').getByText('Aurora Fields').first().click();
await expect(activeView(app)).toHaveAttribute(
'data-active-view',
'explore-artist-details',
);
}
test.describe('the back gesture', () => {
test('leaves a detail view for the view it was opened from', async ({
app,
}) => {
await openAnArtist(app);
await app.goBack();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'artists');
});
test('walks back through primary views, one press per navigation', async ({
app,
}) => {
await app.getByTestId('nav-tracks').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks');
await app.getByTestId('nav-albums').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
await app.goBack();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks');
// Forward is free once back works, and it is what proves the entry
// was restored rather than the view merely re-rendered.
await app.goForward();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
});
test('an in-app back button consumes exactly one entry', async ({ app }) => {
await app.getByTestId('nav-tracks').click();
await openAnArtist(app);
// The detail view's own back button and the phone's gesture are the
// same press: if each popped its own stack, this would land two
// navigations back instead of one.
await app
.locator('explore-artist-details')
.getByRole('button', { name: 'Back to explore' })
.click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'artists');
await app.goBack();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks');
});
});
+58 -11
View File
@@ -26,9 +26,7 @@ import type { Page } from '@playwright/test';
*/
test.describe('Explore before anyone has typed', () => {
test.beforeEach(async ({ app }) => {
// Idempotent, so running it per test costs one count query when a
// catalog is already there — which is every developer machine.
await stageCatalogIfEmpty(app);
await stageCatalog(app);
await app.getByTestId('nav-explore').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
@@ -125,8 +123,7 @@ test.describe('Explore before anyone has typed', () => {
});
/**
* Give the app a catalog if it has none, so the empty-index environment
* still exercises the shelves rather than skipping them.
* Give the app the catalog these shelves are written against.
*
* Deliberately shaped: two artists with albums (one of them with
* three), and a third with none. The three albums are what "one album
@@ -135,9 +132,23 @@ test.describe('Explore before anyone has typed', () => {
* above it and correctly skipped — the first version of this fixture
* had only two, and the artists shelf was rightly omitted, which read
* as a broken page.
*
* **Unconditional, and it used to ask whether the catalog was empty.**
* "Any rows at all" is the wrong question: the backend is one shared
* process with one database, so a *single* row left by another spec
* file — `requested-badge` stages one album — satisfies that gate and
* this suite then draws a shelf page with no artist card on it and
* times out looking for one. It survives a suite run, so it is the
* second local `make e2e` that fails and the first that passes, which
* is the least useful order. CI never sees it: every run there is a
* fresh YJ_HOME.
*
* The inserts are `INSERT OR IGNORE` keyed on the MBID, so running
* this per test is idempotent, and adding seven low-popularity rows to
* a developer machine's real million-row catalog changes nothing the
* shelves show.
*/
async function stageCatalogIfEmpty(app: Page): Promise<void> {
if ((await catalogRows(app)) > 0) return;
async function stageCatalog(app: Page): Promise<void> {
// The catalog stores an MBID as its 16 raw bytes and an entity type
// as a small integer, so a staged row has to be spelled the way the
@@ -185,15 +196,51 @@ async function stageCatalogIfEmpty(app: Page): Promise<void> {
expect(result.status, `staging failed: ${result.body}`).toBe(200);
// …and `OR IGNORE` means a 200 is not a write. A CHECK the row
// violates is *ignored*, not reported, so the count below is the
// violates is *ignored*, not reported, so the check below is the
// only thing that can tell staging from silence.
//
// It is `0 or 1`, not `1`, because this helper is now
// unconditional: the second call of a run legitimately writes
// nothing. What must hold either way is that the rows are *there*,
// which is what the assertion after the loop says — a stronger
// statement than "this insert wrote something", and the one that
// actually protects the fixture.
expect(
(JSON.parse(result.body) as { rowsAffected?: number }).rowsAffected,
`staged nothing: ${result.body}`,
).toBe(1);
`staging error: ${result.body}`,
).toBeLessThanOrEqual(1);
}
expect(await catalogRows(app)).toBeGreaterThan(0);
// Every staged row is present, whoever put it there. An MBID that
// fails `CHECK(length(mbid) = 16)` is silently dropped by OR IGNORE,
// and this is where that shows up.
expect(await stagedRowCount(app), 'the staged catalog is incomplete')
.toBe(rows.length);
}
/** How many of the staged fixture rows are in the catalog. */
async function stagedRowCount(app: Page): Promise<number> {
const result = await app.evaluate(async () => {
const res = await fetch('/__test/sql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sql: `SELECT COUNT(*) AS n FROM explore_index
WHERE artist_name IN ('Staged Alpha', 'Staged Beta',
'Staged Gamma')`,
}),
});
return { status: res.status, body: await res.text() };
});
expect(result.status, `count failed: ${result.body}`).toBe(200);
const parsed = JSON.parse(result.body) as {
rows?: { n?: number }[];
};
return parsed.rows?.[0]?.n ?? 0;
}
/**
+23 -19
View File
@@ -199,35 +199,39 @@ test.describe('the shell reflows rather than hiding what does not fit', () => {
});
});
test('what does not fit sideways can be scrolled to', async ({ app }) => {
test('nothing needs scrolling to at 320px, because it all fits', async ({ app }) => {
// 320 CSS px is 400% page zoom of a 1280px viewport, which is the
// size 1.4.10 names. The shell is 784px wide there, so 464px of the
// app — the job indicator and the queue button among it — used to
// be behind `overflow: hidden` with no way to reach it.
// size 1.4.10 names.
//
// **This assertion is the inverse of the one it replaces, and that
// is the fix landing rather than the test being weakened.** The
// shell used to be 784px wide here, so 464px of the app — the job
// indicator and the queue button among it — sat behind
// `overflow: hidden` with no way to reach it; making the axis
// scrollable was the remedy available at the time. 016 B2's phone
// layout reflows instead: below 600px the sidebar becomes a bottom
// tab bar, the header's controls shrink, and the shell measures
// exactly 320px in a 320px viewport. Reflow is what 1.4.10 asks
// for; being able to scroll to the overflow was the concession.
await app.setViewportSize({ width: 320, height: 256 });
// A *gesture*, not `scrollLeft = 9999`: `overflow: hidden` still
// permits programmatic scrolling, so the obvious probe passes on
// the build that has the bug. It did, first time.
await app.mouse.move(160, 20);
await app.mouse.wheel(400, 400);
await app.waitForTimeout(200);
const reach = await app.evaluate(() => {
const fit = await app.evaluate(() => {
const se = document.scrollingElement!;
return { left: se.scrollLeft, top: se.scrollTop };
return {
scrollWidth: se.scrollWidth,
clientWidth: document.documentElement.clientWidth,
scrollHeight: se.scrollHeight,
clientHeight: document.documentElement.clientHeight,
};
});
expect(reach.left).toBeGreaterThan(0);
expect(fit.scrollWidth).toBeLessThanOrEqual(fit.clientWidth);
// And the vertical axis stays fixed, which is what keeps the
// transport where a desktop player's transport belongs.
expect(reach.top).toBe(0);
// transport where a player's transport belongs.
expect(fit.scrollHeight).toBeLessThanOrEqual(fit.clientHeight);
await app.evaluate(() => {
document.scrollingElement!.scrollLeft = 0;
});
await app.setViewportSize({ width: 1440, height: 900 });
});
+127
View File
@@ -0,0 +1,127 @@
import { test, expect } from '../support/fixtures.js';
/**
* Long-press is the touch route to a context menu (plan 016 B2 phase 3).
*
* The component tier proves the gesture in isolation, against markup it
* built itself. What it cannot prove is the half that made this one
* listener instead of six: that the synthetic event reaches the handler
* a *real* component bound — `track-list` delegates its `contextmenu`
* on the `lit-virtualizer` rather than binding one per row — and that
* the real `wa-popup` menu opens from it, which is a path with its own
* history of opening and then refusing to work (see
* `menu-keyboard.spec.ts`).
*
* The pointer events are dispatched rather than performed: this project
* runs Desktop Chrome and Desktop Safari, neither of which has touch,
* and a device tier does not exist. So this is honest about what it
* checks — the app's own listeners, on the app's own DOM, from the
* events a touch would produce — and not about a real finger.
*/
/** A common small phone, as in `phone-shell.spec.ts`. */
const PHONE = { width: 390, height: 844 };
/** Comfortably past the module's 500ms hold. */
const HELD = 900;
type Page = import('@playwright/test').Page;
/** The track list's menu panel, or null while it is not rendered. */
const panel = (page: Page) =>
page.evaluate(() => {
const el = document
.querySelector('track-list')
?.shadowRoot?.querySelector('.context-menu-panel');
if (!el) return null;
return {
role: el.getAttribute('role'),
label: el.getAttribute('aria-label'),
items: el.querySelectorAll('[role="menuitem"]').length,
};
});
/**
* Press the first track row, optionally dragging partway through — the
* shape of a scroll that begins on a row, which must not open a menu.
*/
async function pressFirstRow(
page: Page,
opts: { driftY?: number } = {},
): Promise<void> {
await page.evaluate((drift) => {
// `.track-row`, not `[role="row"]`: the column header is a row too,
// and it is the *first* one -- a press on it is correctly ignored,
// which reads exactly like the gesture not working.
const row = document
.querySelector('track-list')
?.shadowRoot?.querySelector('.track-row');
if (!row) throw new Error('no track row to press');
const box = row.getBoundingClientRect();
const x = Math.round(box.left + box.width / 2);
const y = Math.round(box.top + box.height / 2);
const send = (type: string, dy = 0) =>
row.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
pointerType: 'touch',
isPrimary: true,
clientX: x,
clientY: y + dy,
}),
);
send('pointerdown');
if (drift) send('pointermove', drift);
}, opts.driftY ?? 0);
}
test.describe('long-press opens the track menu', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(PHONE);
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
});
test.afterEach(async ({ app }) => {
// Every other spec file runs against a desktop, and the viewport
// belongs to the shared context rather than to this file.
await app.setViewportSize({ width: 1440, height: 900 });
});
test('reaches the delegated handler and opens the real menu', async ({
app,
}) => {
await expect.poll(() => panel(app)).toBeNull();
await pressFirstRow(app);
await expect
.poll(() => panel(app), { timeout: HELD + 2000 })
.toMatchObject({ role: 'menu', label: 'Track actions' });
// The same panel Shift+F10 opens, items and all -- not an empty
// popup that happened to become visible.
expect((await panel(app))?.items).toBeGreaterThan(0);
});
test('does not open one for a press that turns into a scroll', async ({
app,
}) => {
await pressFirstRow(app, { driftY: 40 });
await app.waitForTimeout(HELD);
expect(await panel(app)).toBeNull();
});
});
+170
View File
@@ -0,0 +1,170 @@
import { test, expect } from '../support/fixtures.js';
/**
* The phone shell (plan 016 B2, phase 1).
*
* This is the tier that can actually answer the question. Wails v3's
* server mode serves the real frontend, so a Chromium at 390×844 is the
* same document an Android WebView renders — the only thing a device
* adds here is the WebView's own quirks, and CI runs the WebKit half
* for exactly that reason.
*
* The assertions are the three things B2 is *for*: the eleven-item
* sidebar is gone, the four destinations plan 016 committed to are
* reachable with a thumb, and nothing scrolls sideways. The last one is
* the one that hides: `overflow-x: auto` on `body` means a shell that
* does not fit produces a scrollbar rather than a broken layout, which
* looks survivable in a screenshot and is not.
*/
/** A common small phone. Narrower than any device this is likely to meet. */
const PHONE = { width: 390, height: 844 };
/** The narrowest thing still sold, near enough. */
const SMALL_PHONE = { width: 360, height: 780 };
const horizontalOverflow = (page: import('@playwright/test').Page) =>
page.evaluate(() => ({
scrollWidth: document.body.scrollWidth,
clientWidth: document.body.clientWidth,
}));
test.describe('the shell on a phone', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(PHONE);
});
test('replaces the sidebar with a bottom tab bar', async ({ app }) => {
await expect(app.locator('div.sidebar')).toBeHidden();
const nav = app.locator('bottom-nav');
await expect(nav).toBeVisible();
// Four tabs and a way to everything else, which is the shape the
// plan argues for: a tab bar is 3-5 items before the targets stop
// being thumb-sized.
for (const id of ['home', 'albums', 'tracks', 'playlists', 'more']) {
await expect(app.getByTestId(`tab-${id}`)).toBeVisible();
}
});
test('navigates from a tab', async ({ app }) => {
await app.getByTestId('tab-albums').click();
await expect(app.getByTestId('main-content'))
.toHaveAttribute('data-active-view', 'albums');
await app.getByTestId('tab-home').click();
await expect(app.getByTestId('main-content'))
.toHaveAttribute('data-active-view', 'home');
});
test('reaches the views with no tab through the drawer', async ({ app }) => {
await app.getByTestId('tab-more').click();
// Scoped to the drawer: the desktop sidebar is still in the DOM
// (hidden by the media query, not removed), so an unscoped testid
// matches two elements and Playwright's strict mode refuses --
// which is the right complaint, since the two really are different
// buttons.
//
// The drawer holds the *same* sidebar the desktop uses, so Settings
// -- which a phone still needs occasionally -- is reachable without
// a second list of destinations to keep in step.
const settings = app
.getByTestId('nav-drawer')
.getByTestId('nav-settings');
await expect(settings).toBeVisible();
await settings.click();
await expect(app.getByTestId('main-content'))
.toHaveAttribute('data-active-view', 'settings');
// And the drawer gets out of the way once it has done its job.
await expect(app.getByTestId('nav-drawer')).toBeHidden();
});
test('has a named drawer', async ({ app }) => {
await app.getByTestId('tab-more').click();
// The a11y snapshot never prints a dialog's name, so this asks for
// the role and the name together -- which is the check that caught
// eleven unnamed dialogs.
await expect(
app.getByRole('dialog', { name: 'All views' }),
).toBeVisible();
});
for (const vp of [PHONE, SMALL_PHONE]) {
test(`does not scroll sideways at ${vp.width}×${vp.height}`, async ({ app }) => {
await app.setViewportSize(vp);
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content'))
.toHaveAttribute('data-active-view', 'tracks');
const { scrollWidth, clientWidth } = await horizontalOverflow(app);
expect(scrollWidth, `body overflows by ${scrollWidth - clientWidth}px`)
.toBeLessThanOrEqual(clientWidth);
});
}
test('opens the full-screen now playing, and comes back', async ({ app }) => {
// Something has to be playing for the mini player to be a way in.
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content'))
.toHaveAttribute('data-active-view', 'tracks');
await app.locator('track-list .track-row').first().dblclick();
await expect(app.getByTestId('now-playing-title')).not.toBeEmpty();
await app.getByTestId('open-now-playing').click();
await expect(app.getByTestId('main-content'))
.toHaveAttribute('data-active-view', 'now-playing');
// The seek bar and volume that phase 1 took out of the bottom bar
// are here, and they are the *same* components -- this view
// composes the transport rather than reimplementing it.
await expect(app.locator('now-playing-view seek-bar')).toBeVisible();
await expect(app.locator('now-playing-view volume-control')).toBeVisible();
// Back goes where the user came from, through the nav stack.
await app.getByTestId('npv-back').click();
await expect(app.getByTestId('main-content'))
.toHaveAttribute('data-active-view', 'tracks');
});
test('offers no way in on a desktop, where the bar is whole', async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
// The button exists in the markup at every size; CSS decides. If
// this becomes visible on a desktop it is a 48px hit target over
// the cover art, swallowing the clicks that open the preview.
await expect(app.getByTestId('open-now-playing')).toBeHidden();
});
test('keeps the transport, minus what a thumb cannot use', async ({ app }) => {
// The player bar stays: this is a music player, and what is playing
// has to be visible and pausable from every view.
await expect(app.locator('audio-player')).toBeVisible();
await expect(app.locator('now-playing')).toBeVisible();
// Volume is the hardware keys' job on a phone, and a 4px seek bar
// is not a thumb target -- both belong to a later phase's
// full-screen now-playing view.
await expect(app.locator('audio-player volume-control')).toBeHidden();
});
});
test.describe('the desktop shell is unchanged', () => {
test('keeps the sidebar and hides the tab bar', async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
await expect(app.locator('div.sidebar')).toBeVisible();
await expect(app.locator('bottom-nav')).toBeHidden();
});
});
@@ -10,6 +10,35 @@
// @ts-ignore: Unused imports
import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as $models from "./models.js";
/**
* CheckStorageAccess asks the filesystem rather than the permission
* system.
*
* On Android this app holds MANAGE_EXTERNAL_STORAGE, which the user
* grants on a Settings screen rather than in a dialog — so it can be
* refused, revoked later, or simply never answered, and the permission
* API is one more thing that can disagree with reality. Reading the
* directory is the question the library scanner will actually ask, so
* it is the one worth answering.
*
* It is deliberately not an error return: "we cannot read your music
* yet" is a state the UI renders, not a failure of the call.
*/
export function CheckStorageAccess(): $CancellablePromise<$models.StorageAccess> {
return $Call.ByID(1661980006);
}
/**
* DefaultBrowseRoot is where a folder picker should open.
*/
export function DefaultBrowseRoot(): $CancellablePromise<string> {
return $Call.ByID(497852148);
}
/**
* DirectoryPicker opens a directory selection dialog.
*
@@ -20,6 +49,22 @@ export function DirectoryPicker(): $CancellablePromise<string> {
return $Call.ByID(3245034282);
}
/**
* HasNativeDirectoryPicker reports whether this platform can open a
* directory dialog at all.
*
* It is asked of the backend rather than tested in the frontend with
* `System.IsAndroid()`, for three reasons. The dialog *is* backend code
* — `DirectoryPicker` above — so this is the same package saying what
* it can do. It answers for iOS too without the frontend enumerating
* platforms. And it makes the frontend's fallback testable through the
* ordinary transport fake instead of a module mock of the Wails
* runtime, whose platform helpers read build constants.
*/
export function HasNativeDirectoryPicker(): $CancellablePromise<boolean> {
return $Call.ByID(1028901937);
}
/**
* ImageFilePicker opens a file selection dialog filtered to image
* files (JPEG, PNG). Returns the selected file path, or empty
@@ -29,6 +74,30 @@ export function ImageFilePicker(): $CancellablePromise<string> {
return $Call.ByID(3408786006);
}
/**
* ListDirectories returns the directories directly inside path, so the
* frontend can draw a folder picker.
*
* **It exists because Android has no directory picker.** Wails' file
* dialog can choose directories on every desktop platform, and on
* Android it returns an error: the Storage Access Framework yields tree
* URIs rather than filesystem paths, and a path is what this app's
* entire library model is keyed on. Rather than teach the backend about
* tree URIs, the app browses the filesystem itself — which it can do
* because it holds all-files access (see the manifest).
*
* Three rules, each of which a picker gets wrong if it is not stated:
* only directories are returned, because the caller is choosing a
* library root and files are noise; unreadable children are skipped
* rather than failing the whole listing, since Android's storage root
* contains directories no app may enter; and hidden directories are
* omitted, because a music library is not in one and `.thumbnails`
* alone would swamp the list.
*/
export function ListDirectories(path: string): $CancellablePromise<$models.DirListing> {
return $Call.ByID(692624856, path);
}
/**
* PlaylistFilePicker opens a file selection dialog filtered
* to M3U/M3U8 playlist files. Multiple files may be selected.
@@ -5,3 +5,9 @@ import * as FrontendUtil from "./frontendutil.js";
export {
FrontendUtil
};
export type {
DirEntry,
DirListing,
StorageAccess
} from "./models.js";
@@ -0,0 +1,34 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* DirEntry is one selectable directory in a listing.
*/
export interface DirEntry {
"name": string;
"path": string;
}
/**
* DirListing is one level of the filesystem, as a folder picker needs
* it: where we are, what is above, and the directories below.
*
* Parent is empty at a root, which is what tells the UI not to draw an
* "up" affordance rather than having it compute that from the path
* separator.
*/
export interface DirListing {
"path": string;
"parent": string;
"entries": DirEntry[] | null;
}
/**
* StorageAccess reports whether the app can actually read the place the
* user's music lives.
*/
export interface StorageAccess {
"root": string;
"readable": boolean;
"reason": string;
}
+121
View File
@@ -41,6 +41,18 @@ body {
overflow-y: hidden;
}
/* Above the phone breakpoint the tab bar does not exist. It is in the
markup unconditionally and eagerly, for the reason notification-host
is: navigation that has to fetch a chunk before it can navigate is
not navigation. */
@media (min-width: 600px) {
bottom-nav {
display: none;
}
}
p {
margin: 0;
/* I want to set paragraph margins myself */
@@ -130,6 +142,8 @@ body div.sidebar {
contain: layout style paint;
}
.bottom-bar {
grid-area: bottom-bar;
padding: 0.25em;
@@ -241,3 +255,110 @@ body div.sidebar {
pointer-events: none !important;
contain: strict !important;
}
/* ===================================================================
The phone shell (plan 016 B2).
**This section is last on purpose.** A media query adds no
specificity, so `@media (max-width: 599px) { .title { … } }` placed
above the plain `.title` rule loses to it -- which is exactly what
happened when this landed in the middle of the file: the header kept
its 2em gutters, its 16px gap and its 24px title on a 390px phone,
and every one of these declarations was dead. Nothing failed,
because the shell fits for a different reason (the `min-width: 0`
below and each component's own media query), so a screenshot was
what caught it.
600px, not the sidebar's 900: 900 is a *laptop* and the response to
it is a narrower sidebar, which is still a sidebar. Below 600 there
is no room for one at all -- 360px of viewport over a 200px nav is
not a layout -- so the navigation moves to the bottom, where a thumb
is, and the eleven-item list moves into `bottom-nav`'s drawer.
=================================================================== */
@media (max-width: 599px) {
body {
grid-template:
"top-bar" 3.25em
"main-panel" 1fr
"bottom-bar" auto
"bottom-nav" auto
/ 1fr;
/* Nothing may scroll sideways here. On a desktop the shell is
allowed to overflow a zoomed-in window (a11y.21 above); a
phone *is* the small viewport, so the shell has to fit it. */
overflow-x: hidden;
}
body div.sidebar {
display: none;
}
bottom-nav {
grid-area: bottom-nav;
}
/* The 2em gutters are half a thumb each at this width, and the
subtitle is already gone from 900 down.
`min-width: 0` is the load-bearing half. A grid item's implicit
minimum is `auto` -- its content -- so a header whose children
ask for 580px makes the *body* 580px wide inside a 360px
viewport, and `overflow-x: hidden` then hides the right-hand
third of the app rather than fitting it. Every box between the
viewport and the content that must shrink needs this. */
.top-bar {
padding-left: 0.75em;
padding-right: 0.75em;
gap: 0.5em;
min-width: 0;
overflow: hidden;
}
.content-area,
.main-panel,
.bottom-bar {
min-width: 0;
}
.title {
font-size: 1.1em;
}
/* The search box is the one header control worth its width; the
library filter is a rarely-changed setting and reachable from
the drawer's Settings. */
.top-bar library-filter {
display: none;
}
/* The full-screen now-playing view *is* the transport, so the bar
repeating it underneath is 4em of a small screen spent saying
the same thing twice -- visible in a screenshot, invisible to
every assertion about either one.
`:has()` rather than a class toggled from index.ts: which view
is showing is already published as an attribute, and a second
expression of the same fact is a second thing to keep in step.
The view carries its own queue button, because this is where
that one lived. */
body:has(#main-content[data-active-view="now-playing"]) .bottom-bar {
display: none;
}
.top-bar search-bar {
flex: 1 1 auto;
min-width: 0;
}
}
@media (max-width: 599px) {
.bottom-bar {
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 0.25em;
}
.bottom-bar audio-player {
margin: 0.25em;
}
}
+7
View File
@@ -41,6 +41,13 @@
<wa-icon name="list"></wa-icon>
</button>
</footer>
<!-- The phone's primary navigation, hidden above 600px by
index.css. Eager rather than a chunk, for the reason
notification-host is: it is the only way to move around the
app on a phone. After the footer, because that is where it
renders -- the tab bar sits below the transport, and DOM order
is what a screen reader and the tab sequence follow. -->
<bottom-nav></bottom-nav>
<first-run-wizard></first-run-wizard>
<notification-host></notification-host>
<shortcuts-overlay></shortcuts-overlay>
+95 -25
View File
@@ -21,6 +21,7 @@ import '@components/audio-player/audio-player.ts';
import '@components/track-list/track-list.ts';
import '@components/now-playing/now-playing.ts';
import '@components/sidebar/app-sidebar.ts';
import '@components/bottom-nav/bottom-nav.ts';
import '@components/queue-panel/queue-panel.ts';
import '@components/search-bar/search-bar.ts';
import '@components/library-filter/library-filter.ts';
@@ -49,6 +50,7 @@ import '@store/theme-store';
// registers the document keydown listener for global shortcuts.
import './src/services/keyboard-shortcut-service';
import { activateView, deactivateView } from '@utils/view-lifecycle';
import { installLongPressContextMenu } from '@utils/long-press';
import {
hasTrackPayload,
getDragPayload,
@@ -63,6 +65,11 @@ setBasePath('/dist/webawesome');
// the session.
registerBundledIcons();
// The touch equivalent of a right-click, installed once for every menu
// in the app rather than per component. Harmless on a desktop: it acts
// on `pointerType === 'touch'` only.
installLongPressContextMenu();
// ---------------------------------------------------------------------------
// View caching navigation system
// ---------------------------------------------------------------------------
@@ -126,6 +133,11 @@ const DETAIL_LOADERS: Record<string, () => Promise<unknown>> = {
import('@components/explore-artist-details/explore-artist-details.js'),
'explore-album-details': () =>
import('@components/explore-album-details/explore-album-details.js'),
// A detail view rather than a primary one on purpose: it is
// somewhere you go and come back from, so the nav stack carries
// the way out (016 B2 phase 2).
'now-playing': () =>
import('@components/now-playing-view/now-playing-view.ts'),
};
// Opened from a menu rather than by navigating, so they have no entry
@@ -148,10 +160,6 @@ const viewCache = new Map<string, HTMLElement>();
let currentViewEl: HTMLElement | null = null;
let currentDetailEl: HTMLElement | null = null;
/** Navigation history stack for back-button support in detail views. */
const navStack: Array<{ view: string; [key: string]: any }> = [];
/** The current navigation detail (so we can push it onto the stack). */
let currentNavDetail: { view: string; [key: string]: any } = { view: 'home' };
const mainContent = document.getElementById('main-content');
@@ -184,6 +192,71 @@ document.addEventListener('navigate', (e: Event) => {
void handleNavigate((e as CustomEvent).detail);
});
// ---------------------------------------------------------------------------
// The platform's back gesture
// ---------------------------------------------------------------------------
// Android's back button is not a keystroke the page can bind: the
// scaffold's `MainActivity.onBackPressed` asks `webView.canGoBack()` and
// otherwise finishes the activity. This app never touched `history`, so
// that was always false and back quit the app from any depth -- reported
// from a device as "back does not navigate back".
//
// So a navigation is a history entry, and back is `popstate`. It hooks
// the platform's own mechanism rather than a JNI callback of our own,
// which is the same reason `events.ts` hooks the runtime's transport:
// the Java half needs no change, and the behaviour is testable in a
// browser (`page.goBack()`) instead of only on a phone.
//
// Two rules keep the two stacks from disagreeing. A navigation that
// *came from* history pushes nothing (`_isBack`), or going back would
// deepen the stack it is unwinding. And the in-app back buttons --
// `navigate-back`, which the detail views and `now-playing-view` fire --
// go through `history.back()` rather than popping `navStack`
// themselves, so one press cannot consume two entries.
/** The navigation an entry stands for. `undefined` on the entry that
* predates the app's own routing, which is the one back exits from. */
type NavState = { yjNav?: { view: string; [key: string]: any } };
/** Whether the app's first navigation has been recorded. It *replaces*
* the launch entry rather than pushing, or every launch would cost one
* back press before the app would exit. */
let historyStarted = false;
/** How many entries this session has pushed beyond that first one --
* i.e. how deep back can go while staying inside the app. */
let pushedEntries = 0;
function recordNavigation(detail: { view: string; [key: string]: any }): void {
// `_isBack` is bookkeeping, not destination: keeping it in the entry
// would make a replayed navigation claim to be a back-navigation.
const { _isBack: _ignored, ...nav } = detail;
const state: NavState = { yjNav: nav };
// Same URL, deliberately: the app has no routes, and a path a
// reload cannot resolve is worse than no path at all.
if (historyStarted) {
history.pushState(state, '');
pushedEntries += 1;
} else {
history.replaceState(state, '');
historyStarted = true;
}
}
window.addEventListener('popstate', (e: PopStateEvent) => {
const nav = (e.state as NavState | null)?.yjNav;
// Before the app's first navigation, or an entry somebody else
// pushed: nothing to restore, and the activity should be free to
// finish.
if (!nav) return;
pushedEntries = Math.max(0, pushedEntries - 1);
void handleNavigate({ ...nav, _isBack: true });
});
async function handleNavigate(
detail: { view: string; [key: string]: any },
): Promise<void> {
@@ -193,6 +266,8 @@ async function handleNavigate(
const seq = ++navSeq;
if (!detail._isBack) recordNavigation(detail);
// Bookkeeping stays synchronous with the click: the search box's
// scope and the active-view attribute describe the navigation that
// was *asked for*, and are what the rest of the app and the e2e
@@ -206,9 +281,6 @@ async function handleNavigate(
// --- Primary (cacheable) views ----------------------------------------
if (view in VIEW_TAGS) {
// Navigating to a primary view clears the history stack.
navStack.length = 0;
// Remove any active detail view first
if (currentDetailEl) {
deactivateView(currentDetailEl);
@@ -241,7 +313,6 @@ async function handleNavigate(
// the way out. Either way this is the call that starts it.
activateView(target);
currentViewEl = target;
currentNavDetail = { view };
return;
}
@@ -250,12 +321,6 @@ async function handleNavigate(
if (seq !== navSeq) return;
// --- Detail (ephemeral) views -----------------------------------------
// Push the current view onto the nav stack before switching
// (unless this is a back-navigation, which already popped).
if (!detail._isBack) {
navStack.push({ ...currentNavDetail });
}
// Hide the current primary view
if (currentViewEl) {
currentViewEl.classList.add('view-hidden');
@@ -268,8 +333,6 @@ async function handleNavigate(
currentDetailEl = null;
}
currentNavDetail = { ...detail };
switch (view) {
case 'artist-details': {
const { artistId, artistName } = detail;
@@ -304,6 +367,13 @@ async function handleNavigate(
currentDetailEl = spEl;
break;
}
case 'now-playing': {
const npEl = document.createElement('now-playing-view');
mainContent.appendChild(npEl);
currentDetailEl = npEl;
break;
}
case 'genre-details': {
const { genreName } = detail;
const genreEl = document.createElement('genre-details');
@@ -406,16 +476,16 @@ function schedule(fn: () => void): void {
setTimeout(fn, 200);
}
// Navigate-back: pop the nav stack and re-dispatch as a regular navigate.
// Navigate-back: the in-app back buttons, which are the same press as
// the phone's. It goes through the history rather than a stack of its
// own, so one press is one entry however it arrived -- two stacks is
// how a detail view's own button and the back gesture come to disagree.
//
// At the root there is nothing of ours to go back to, and going back
// anyway would leave the app: the depth check is what stops a stray
// `navigate-back` closing it.
document.addEventListener('navigate-back', () => {
const prev = navStack.pop();
if (prev) {
document.dispatchEvent(new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: { ...prev, _isBack: true },
}));
}
if (pushedEntries > 0) history.back();
});
// Navigate to the user's configured launch page. Falls back to 'home'
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/></svg>

After

Width:  |  Height:  |  Size: 608 B

@@ -32,6 +32,23 @@ export class AudioPlayer extends LitElement {
flex: 1;
}
/* The phone transport (plan 016 B2): the buttons, and nothing
else. A media query inside a shadow root is answered by the
viewport, not by the host, so this is the component saying what
it drops at phone width rather than the shell reaching in.
Volume goes because the hardware keys own it on a phone --
Android routes them to the media stream, which is also why
mediacontrols' Android handler implements no volume callback.
The seek bar goes because a 4px-tall target dragged with a thumb
is not a seek control; seeking belongs to the full-screen
now-playing view, which is the next phase. */
@media (max-width: 599px) {
volume-control,
seek-bar {
display: none;
}
}
`];
override render() {
@@ -27,6 +27,18 @@ export class SeekBar extends LitElement {
private showRemaining: boolean = true;
static override styles = [designTokens, waSliderLabel, css`
/* 12px below the phone breakpoint. The bottom bar's seek bar is
display:none there (016 B2 phase 1), so the only instance a
viewport media query can reach at that width is the full-screen
now-playing view's -- which is exactly the one a thumb uses.
The track size lives on wa-slider inside this shadow root, so a
custom property set by the host would not reach it. */
@media (max-width: 599px) {
wa-slider {
--track-size: 12px;
}
}
wa-slider {
--track-size: 6px;
flex: 1;
@@ -0,0 +1,253 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/drawer/drawer.js';
import type WaDrawer from '@awesome.me/webawesome/dist/components/drawer/drawer.js';
import { designTokens } from '../../styles/tokens.css';
import '../sidebar/app-sidebar.js';
import { nameDialog } from '@utils/name-dialog';
type View = 'home' | 'albums' | 'tracks' | 'playlists';
interface Tab {
id: View;
label: string;
icon: string;
}
/**
* The phone's primary navigation: a bottom tab bar, shown only below
* the phone breakpoint (index.css owns that; this element is
* `display: none` above it).
*
* **Four destinations and a way to everything else.** A tab bar is
* three to five items before the targets stop being thumb-sized —
* 360 px over eleven sidebar entries is 32 px each — so the four here
* are the ones plan 016's subset says a phone is *for*, and "More"
* opens the existing `<app-sidebar>` in a drawer. That is deliberately
* a reuse rather than a second nav: two lists of destinations is two
* places to add the next view to, and the sidebar already carries the
* drag-to-navigate behaviour, the active state and the labels.
*
* It emits the same bubbling, composed `navigate` event the sidebar
* does, so `index.ts` needs no knowledge of it, and it listens for that
* event globally for the same reason the sidebar does: a navigation it
* did not send (a card click, a detail view, the drawer) still has to
* move the highlight.
*/
@customElement('bottom-nav')
export class BottomNav extends LitElement {
static override styles = [designTokens, css`
:host {
display: block;
background-color: var(--yj-bg-elevated, #343a40);
border-top: 1px solid var(--yj-border, #495057);
/* The home indicator on a gesture-navigation phone sits
under the last few pixels of the viewport, so the bar
pads itself out of the way where the browser reports
one and by nothing where it does not. */
padding-bottom: env(safe-area-inset-bottom, 0);
}
nav ul {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
margin: 0;
padding: 0;
list-style: none;
}
button {
width: 100%;
/* 48px is the smallest target this should ever be; the
label sits under the icon rather than beside it, which
is what keeps five of them legible at 360px. */
min-height: 48px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 4px 0;
background: none;
border: none;
color: var(--yj-text-secondary, #adb5bd);
cursor: pointer;
font-family: inherit;
font-size: var(--yj-font-size-xs, 0.7rem);
}
button wa-icon {
font-size: 1.15rem;
}
button.active {
color: var(--yj-accent, #ffd43b);
}
button:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: -2px;
}
.label {
/* A tab label is an aid, not the name: the button's own
accessible name comes from its text, and truncating it
visually does not change that. */
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
wa-drawer::part(body) {
padding: 0;
}
app-sidebar {
/* The sidebar sizes itself inline and collapses to icons
below 900px, which is every phone. In the drawer there
is room for the labels, so it is told not to. */
height: 100%;
}
`];
@state()
private activeView = 'home';
/**
* Whether the drawer has been asked for.
*
* The sidebar inside it is rendered only while this is true, and
* that is not an optimisation. `app-sidebar` carries a
* `data-testid` per destination, so a second copy standing by in
* the DOM makes every `nav-*` testid ambiguous **for the whole
* app** -- 30 existing specs failed with "strict mode violation:
* resolved to 2 elements" on a desktop viewport where this element
* is not even visible. A duplicate of a shared component is a
* duplicate of its handles.
*/
@state()
private drawerOpen = false;
@query('wa-drawer')
private drawer?: WaDrawer;
private static readonly TABS: Tab[] = [
{ id: 'home', label: 'Home', icon: 'house' },
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
{ id: 'tracks', label: 'Tracks', icon: 'music' },
{ id: 'playlists', label: 'Playlists', icon: 'list' },
];
override connectedCallback() {
super.connectedCallback();
document.addEventListener(
'navigate',
this.onGlobalNavigate as EventListener,
);
}
override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener(
'navigate',
this.onGlobalNavigate as EventListener,
);
}
override updated() {
// Web Awesome renders its heading into its own shadow root and
// never points aria-labelledby at it, so the drawer would
// otherwise be announced unnamed -- the same fix, and the same
// reason, as every wa-dialog in the app. A drawer's shadow root
// has the same shape, so the helper needs no change.
nameDialog(this.drawer);
}
private onGlobalNavigate = (e: Event) => {
const detail = (e as CustomEvent<{ view?: string }>).detail;
if (detail?.view) this.activeView = detail.view;
// A navigation from inside the drawer is the drawer's job done.
this.drawerOpen = false;
};
private openDrawer = () => {
this.drawerOpen = true;
};
/**
* Web Awesome closes itself on Escape and on a click outside, and
* tells us afterwards rather than asking -- so the flag follows the
* element, or the next `open` would be a no-op against a drawer
* that thinks it is already open.
*/
private onDrawerHide = () => {
this.drawerOpen = false;
};
private navigate(view: View) {
this.dispatchEvent(new CustomEvent('navigate', {
detail: { view },
bubbles: true,
composed: true,
}));
}
override render() {
return html`
<nav aria-label="Primary">
<ul>
${BottomNav.TABS.map((tab) => html`
<li>
<button
type="button"
class=${this.activeView === tab.id ? 'active' : ''}
data-testid="tab-${tab.id}"
aria-current=${this.activeView === tab.id
? 'page'
: 'false'}
@click=${() => this.navigate(tab.id)}
>
<wa-icon name=${tab.icon}></wa-icon>
<span class="label">${tab.label}</span>
</button>
</li>
`)}
<li>
<button
type="button"
data-testid="tab-more"
aria-haspopup="dialog"
@click=${this.openDrawer}
>
<wa-icon name="bars"></wa-icon>
<span class="label">More</span>
</button>
</li>
</ul>
</nav>
<wa-drawer
placement="start"
label="All views"
data-testid="nav-drawer"
?open=${this.drawerOpen}
@wa-after-hide=${this.onDrawerHide}
>
${this.drawerOpen
? html`<app-sidebar expanded></app-sidebar>`
: nothing}
</wa-drawer>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'bottom-nav': BottomNav;
}
}
@@ -19,7 +19,6 @@ import {
SetQueueFallback,
} from '@go/config/config.js';
import { GetIndexStatus } from '@go/explore/service.js';
import { DirectoryPicker } from '@go/frontendutil/frontendutil.js';
import { notificationStore } from '@store/notification-store';
import { describeError, explainError } from '@utils/describe-error';
import type * as library from '@go/library/models.js';
@@ -50,6 +49,7 @@ import { confirmAction } from '../confirm-dialog/confirm-dialog';
import { shortcutsStore } from '../../store/shortcuts-store';
import { ShortcutsController } from '../../store/controllers/shortcuts-controller';
import { list } from '@utils/binding';
import { pickDirectory } from '../../utils/pick-directory';
const SCROLL_STORAGE_KEY = 'yj-now-playing-scroll-mode';
const SCROLL_CHANGE_EVENT = 'yj-scroll-mode-changed';
@@ -916,7 +916,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
let dir = '';
try {
dir = await DirectoryPicker();
dir = (await pickDirectory()) ?? '';
if (!dir) return;
@@ -14,7 +14,6 @@ import type {
ProviderField,
} from '@store/download-store';
import { downloadStore } from '@store/download-store';
import { DirectoryPicker } from '@go/frontendutil/frontendutil.js';
import { GetDownloadPreferences, SetDownloadPreferences } from '@go/config/config.js';
import { SetPreferences } from '@go/download/service.js';
import type * as download from '@go/download/models.js';
@@ -23,6 +22,7 @@ import { compact } from '@utils/binding';
import { describeError, explainError } from '@utils/describe-error';
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
import './config-section';
import { pickDirectory } from '../../utils/pick-directory';
/**
* Allowed audio formats for auto-download, mirrored from
@@ -580,7 +580,7 @@ export class DownloadClients extends LitElement {
private browseForFolder = async (field: ProviderField) => {
try {
const dir = await DirectoryPicker();
const dir = await pickDirectory();
if (dir) {
this.draft = { ...this.draft, [field.key]: dir };
@@ -6,9 +6,9 @@ import {
AddLibrary,
GetAllLibrariesWithTrackCounts,
} from '@go/library/library.js';
import { DirectoryPicker } from '@go/frontendutil/frontendutil.js';
import { describeError, explainError } from '@utils/describe-error';
import { nameDialogsIn } from '@utils/name-dialog';
import { pickDirectory } from '../../utils/pick-directory';
/**
* First-run setup wizard.
@@ -243,7 +243,7 @@ export class FirstRunWizard extends LitElement {
this.errorMessage = '';
try {
const dir = await DirectoryPicker();
const dir = await pickDirectory();
if (dir) this.selectedDirectory = dir;
} catch (err) {
@@ -0,0 +1,279 @@
/**
* Choosing a directory, where the platform will not do it for us.
*
* Wails' file dialog can select directories on every desktop platform.
* On Android it returns an error, because the Storage Access Framework
* yields tree URIs rather than filesystem paths — and a path is what
* this app's whole library model is keyed on. So the app browses the
* filesystem itself, through `ListDirectories`, which it can do because
* it holds all-files access.
*
* Deliberately not a general file browser: it lists directories only,
* because the thing being chosen is a library root.
*
* The shape is `confirm-dialog`'s — a promise-returning `choose()` on a
* `wa-dialog`, so callers `await` a path or `null` and there is no
* second dialog pattern in the codebase.
*/
import { LitElement, css, html, nothing } from 'lit';
import { customElement, query, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
import {
DefaultBrowseRoot,
ListDirectories,
} from '@go/frontendutil/frontendutil.js';
import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css';
import { describeError } from '../../utils/describe-error';
import { nameDialogsIn } from '../../utils/name-dialog';
interface Entry {
name: string;
path: string;
}
@customElement('folder-picker')
export class FolderPicker extends LitElement {
@query('wa-dialog') private dialog?: HTMLElement & { open: boolean };
@state() private path = '';
@state() private parent = '';
@state() private entries: Entry[] = [];
@state() private loading = false;
@state() private errorMessage = '';
@state() private isOpen = false;
private settle: ((path: string | null) => void) | null = null;
static override styles = [
designTokens,
srOnly,
css`
:host {
display: contents;
}
wa-dialog::part(dialog) {
background: var(--yj-bg-surface, #212529);
color: var(--yj-text-primary, #fff);
}
.current {
color: var(--yj-text-secondary, #b3b3b3);
font-size: var(--yj-text-sm, 0.8125rem);
margin-bottom: 8px;
overflow-wrap: anywhere;
}
ul {
border: 1px solid var(--yj-border, #495057);
border-radius: 4px;
list-style: none;
margin: 0;
max-height: 45vh;
min-height: 8em;
overflow-y: auto;
padding: 0;
}
li button {
align-items: center;
background: none;
border: 0;
color: inherit;
cursor: pointer;
display: flex;
font: inherit;
gap: 8px;
padding: 10px 12px;
text-align: left;
width: 100%;
}
li button:hover,
li button:focus-visible {
background: var(--yj-bg-elevated, #343a40);
}
.empty {
color: var(--yj-text-secondary, #b3b3b3);
padding: 12px;
}
.error {
color: var(--yj-error-text, #ff8787);
padding: 8px 0;
}
.actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 12px;
}
.actions button {
background: var(--yj-bg-elevated, #343a40);
border: 1px solid var(--yj-border, #495057);
border-radius: 4px;
color: inherit;
cursor: pointer;
font: inherit;
padding: 6px 14px;
}
.actions button.primary {
background: var(--yj-accent, #ffd43b);
border-color: var(--yj-accent, #ffd43b);
color: var(--yj-accent-fg, #000);
}
`,
];
/** Browse. Resolves with an absolute path, or null if cancelled. */
async choose(startAt?: string): Promise<string | null> {
this.settle?.(null);
this.settle = null;
let start = startAt ?? '';
if (!start) {
try {
start = await DefaultBrowseRoot();
} catch {
start = '';
}
}
this.isOpen = true;
await this.load(start);
await this.updateComplete;
if (this.dialog) this.dialog.open = true;
return new Promise<string | null>((resolve) => {
this.settle = resolve;
});
}
private async load(path: string): Promise<void> {
this.loading = true;
this.errorMessage = '';
try {
const listing = await ListDirectories(path);
this.path = listing.path;
this.parent = listing.parent;
this.entries = listing.entries ?? [];
} catch (err) {
// A directory that cannot be read is not a failed picker —
// stay where we are and say so, or the user is stranded
// with an empty dialog and no way back.
this.errorMessage = describeError(
err,
'That folder could not be opened.',
);
console.error('folder-picker: listing failed:', err);
} finally {
this.loading = false;
}
}
private close(path: string | null): void {
const settle = this.settle;
this.settle = null;
if (this.dialog) this.dialog.open = false;
this.isOpen = false;
settle?.(path);
}
override updated(): void {
nameDialogsIn(this.shadowRoot);
}
override render() {
if (!this.isOpen) return nothing;
return html`
<wa-dialog
label="Choose a folder"
data-testid="folder-picker"
@wa-hide=${() => this.close(null)}
>
<p class="current" data-testid="folder-picker-path">
${this.path || '\u2026'}
</p>
${this.errorMessage
? html`<p class="error" role="alert">
${this.errorMessage}
</p>`
: nothing}
<ul data-testid="folder-picker-list">
${this.parent
? html`<li>
<button
@click=${() => void this.load(this.parent)}
data-testid="folder-picker-up"
>
<span aria-hidden="true">\u2191</span> Up one
level
</button>
</li>`
: nothing}
${this.entries.map(
(entry) => html`
<li>
<button
@click=${() => void this.load(entry.path)}
>
<span aria-hidden="true">\u{1F4C1}</span>
${entry.name}
</button>
</li>
`,
)}
${!this.loading && this.entries.length === 0
? html`<li class="empty">No folders here.</li>`
: nothing}
</ul>
<!--
The live region is in the DOM before it has anything to
say, because most screen readers announce a change to a
region they are already watching and ignore one that
appears with its content already in it.
-->
<p class="sr-only" role="status" aria-live="polite">
${this.loading
? 'Loading folders'
: `${this.entries.length} folders in ${this.path}`}
</p>
<div class="actions">
<button @click=${() => this.close(null)}>Cancel</button>
<button
class="primary"
?disabled=${!this.path}
@click=${() => this.close(this.path)}
data-testid="folder-picker-select"
>
Use this folder
</button>
</div>
</wa-dialog>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'folder-picker': FolderPicker;
}
}
@@ -149,6 +149,19 @@ export class JobIndicator extends LitElement {
text-overflow: ellipsis;
}
/* On a phone the ring is the whole indicator: "3 background
jobs" is 114px of a 360px header, and it pushed the
header past the viewport. Only the *visible* label
goes -- the live region in render() is what announces
this, and it is unaffected, so the ring keeps its
accessible name and screen readers keep hearing the
state change. */
@media (max-width: 599px) {
.label {
display: none;
}
}
.alert-dot {
width: 6px;
height: 6px;
@@ -0,0 +1,332 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '../audio-player/controls/player-controls';
import '../audio-player/seekbar/seek-bar';
import '../audio-player/volume-control/volume-control';
import {
artistLink,
albumLink,
exploreLinkStyles,
} from '@utils/explore-link';
import { PlayerController } from '@store/controllers/player-controller';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css';
/**
* What is playing, at the size a phone has room for (plan 016 B2,
* phase 2).
*
* Phase 1 took the seek bar and the volume out of the bottom bar,
* because 4px of height is not a thumb target and a phone's volume
* belongs to its hardware keys. This is where they went: the same
* `<seek-bar>`, `<player-controls>` and `<volume-control>` elements the
* desktop transport uses, given room. **Not copies of them** — a phone
* layout that reimplements the transport is a second transport to fix
* every bug in, and the seek bar in particular carries the
* interpolation rules that took a plan of their own to get right.
*
* It is a *detail* view rather than a primary one: it is somewhere you
* go and come back from, so `index.ts` pushes the current view onto the
* nav stack and Back pops it. That is also why it is not in the tab
* bar — a tab you cannot leave by pressing the same tab again is not a
* tab.
*/
@customElement('now-playing-view')
export class NowPlayingView extends LitElement {
private player = new PlayerController(this);
private favCtrl = new FavoritesController(this);
static override styles = [designTokens, srOnly, exploreLinkStyles, css`
:host {
display: flex;
flex-direction: column;
height: 100%;
box-sizing: border-box;
padding: 0.75em 1em 1.25em;
gap: 0.75em;
background-color: var(--yj-bg-surface, #212529);
overflow-y: auto;
}
header {
display: flex;
align-items: center;
gap: 0.5em;
flex: 0 0 auto;
}
.context {
flex: 1 1 auto;
}
.back {
background: none;
border: none;
color: var(--yj-text-primary, #f8f9fa);
/* 48px is the touch-target floor, and this is the control
that gets a user out of a full-screen view. */
min-width: 48px;
min-height: 48px;
font-size: 1.1rem;
cursor: pointer;
border-radius: 6px;
}
.back:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: -2px;
}
.context {
font-size: var(--yj-font-size-xs, 0.75rem);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--yj-text-secondary, #adb5bd);
}
.art {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
}
.art img,
.art .placeholder {
/* Square, and never taller than the room left over: the
art is the one thing here that would happily push the
transport off the bottom of a short phone. */
width: min(100%, 60vh);
aspect-ratio: 1;
object-fit: cover;
border-radius: 12px;
background-color: var(--yj-bg-elevated, #343a40);
}
.art .placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
color: var(--yj-text-tertiary, #868e96);
}
.meta {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 0.75em;
min-width: 0;
}
.names {
flex: 1 1 auto;
min-width: 0;
}
.title {
font-size: 1.15rem;
font-weight: 600;
margin: 0;
/* Two lines, then an ellipsis. A marquee is the bottom
bar's answer to a 320px box; here there is room to wrap,
and wrapping does not move. */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.artist,
.album {
margin: 0;
font-size: 0.9rem;
color: var(--yj-text-secondary, #adb5bd);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.favorite {
background: none;
border: none;
color: var(--yj-text-secondary, #adb5bd);
min-width: 48px;
min-height: 48px;
font-size: 1.25rem;
cursor: pointer;
border-radius: 6px;
}
.favorite.on {
color: var(--yj-accent, #ffd43b);
}
.favorite:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: -2px;
}
.transport {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 0.5em;
}
/* The seek bar is the reason this view exists. Its own
stylesheet thickens the track below the phone breakpoint --
the track size is set on the wa-slider inside its shadow
root, so a custom property set from here would not reach
it. */
seek-bar {
display: block;
}
.empty {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
color: var(--yj-text-secondary, #adb5bd);
text-align: center;
}
`];
private back() {
this.dispatchEvent(new CustomEvent('navigate-back', {
bubbles: true,
composed: true,
}));
}
/**
* Open the queue.
*
* This view hides the bottom bar (index.css), and the bar is where
* the queue button lives -- so without this, going full-screen
* would take the queue away. It toggles the same `open` attribute
* `index.ts` does, because the panel's state is an attribute on one
* element and a second mechanism for it is a second thing to keep
* in step.
*/
private openQueue() {
document.getElementById('queue-panel')?.setAttribute('open', '');
}
private toggleFavorite() {
const path = this.player.currentTrack?.filePath;
if (path) void this.favCtrl.toggleFavorite(path);
}
override render() {
const track = this.player.currentTrack;
if (!track) {
return html`
${this.renderHeader()}
<p class="empty" data-testid="npv-empty">
Nothing is playing.
</p>
`;
}
const favorited = this.favCtrl.isFavorited(track.filePath);
// The largest kept tier, which is what `saveCoverArt` records as
// the path -- there is no full-resolution original to reach for.
const art = track.coverArtLarge || track.coverArt;
return html`
${this.renderHeader()}
<div class="art">
${art
? html`<img
src=${art}
alt=""
decoding="async"
data-testid="npv-art"
/>`
: html`<div class="placeholder" aria-hidden="true">
<wa-icon name="compact-disc"></wa-icon>
</div>`}
</div>
<div class="meta">
<div class="names">
<h2 class="title" data-testid="npv-title">
${track.title || track.fileName}
</h2>
<p class="artist">
${artistLink(track.artist, track.artistMbid)}
</p>
${track.album
? html`<p class="album">
${albumLink(
track.album,
track.releaseGroupMbid,
undefined,
track.artist,
)}
</p>`
: nothing}
</div>
<button
type="button"
class="favorite ${favorited ? 'on' : ''}"
data-testid="npv-favorite"
aria-pressed=${favorited ? 'true' : 'false'}
aria-label=${favorited
? `Remove ${track.title} from ${this.favCtrl.playlistName}`
: `Add ${track.title} to ${this.favCtrl.playlistName}`}
@click=${this.toggleFavorite}
>
<wa-icon name=${this.favCtrl.iconName}></wa-icon>
</button>
</div>
<div class="transport">
<seek-bar></seek-bar>
<player-controls></player-controls>
<volume-control></volume-control>
</div>
`;
}
private renderHeader() {
return html`
<header>
<button
type="button"
class="back"
data-testid="npv-back"
aria-label="Back"
@click=${this.back}
>
<wa-icon name="chevron-down"></wa-icon>
</button>
<span class="context">Now playing</span>
<button
type="button"
class="back"
data-testid="npv-queue"
aria-label="Show the queue"
@click=${this.openQueue}
>
<wa-icon name="list"></wa-icon>
</button>
</header>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'now-playing-view': NowPlayingView;
}
}
@@ -147,6 +147,41 @@ export class NowPlaying extends LitElement {
position: relative;
}
/* The phone's way into the full-screen now-playing view (016 B2
phase 2). It sits over the cover art rather than being a
thirteenth control in a 360px bar, and it is a *button* rather
than a click handler on the art because it is an action with a
name -- the art itself is decorative and the title beside it
already navigates somewhere else (the catalog page).
CSS owns whether it exists, the same way it does for bottom-nav:
there is no viewport check in the component. */
.expand {
display: none;
}
@media (max-width: 599px) {
.expand {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
padding: 0;
background: none;
border: none;
border-radius: 4px;
cursor: pointer;
/* The art shows through; this is a target, not a picture. */
color: transparent;
}
.expand:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
}
.cover-preview-panel {
width: 500px;
height: 500px;
@@ -392,6 +427,13 @@ export class NowPlaying extends LitElement {
<div class="sr-only" role="status" aria-live="polite">${announcement}</div>
<div class="now-playing">
<div class="cover-art-wrapper">
<button
type="button"
class="expand"
data-testid="open-now-playing"
aria-label="Open now playing"
@click=${this.openNowPlaying}
></button>
<div
class="cover-art"
@mouseenter=${this.handleCoverMouseEnter}
@@ -500,6 +542,15 @@ export class NowPlaying extends LitElement {
`;
}
/** Open the full-screen view. Phone only; see `.expand`. */
private openNowPlaying = () => {
this.dispatchEvent(new CustomEvent('navigate', {
detail: { view: 'now-playing' },
bubbles: true,
composed: true,
}));
};
// ===================================================================
// SCROLL LOGIC
// ===================================================================
@@ -67,6 +67,21 @@ export class SearchBar extends LitElement {
transition: border-color 0.15s ease;
}
/* The 200px floor is a desktop floor. On a phone the header is
the whole width there is, and a min-width in a flex row is a
*hard* one -- it does not shrink, so the header stayed 580px
wide inside a 360px viewport and the shell scrolled
sideways. Measured at 360px: 580 -> 360. */
@media (max-width: 599px) {
:host {
min-width: 0;
}
.search-container {
min-width: 0;
}
}
.search-container:focus-within {
border-color: var(--yj-accent, #ffd43b);
}
+14 -2
View File
@@ -1,5 +1,5 @@
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { customElement, state, property } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
@@ -166,6 +166,17 @@ export class AppSidebar extends LitElement {
@state()
private collapsed = false;
/**
* Keep the labels regardless of the viewport, for a host that has
* made room for them -- `bottom-nav`'s drawer, which is the whole
* screen wide on the phone where this would otherwise auto-collapse
* to icons. The auto-collapse is a *width* response to a narrow
* shell, and inside a drawer the shell is not what the sidebar is
* sharing space with.
*/
@property({ type: Boolean, reflect: true })
expanded = false;
/** The width the user chose, restored when the window grows back. */
private userWidth = DEFAULT_WIDTH;
@@ -344,7 +355,8 @@ export class AppSidebar extends LitElement {
*/
private applyViewportWidth() {
const narrow =
this.narrowViewport?.matches ?? false;
!this.expanded &&
(this.narrowViewport?.matches ?? false);
const width = narrow
? MIN_WIDTH
: this.userWidth;
+1
View File
@@ -22,6 +22,7 @@ solid/arrow-rotate-right
solid/arrows-rotate
solid/arrow-up-short-wide
solid/backward-step
solid/bars
regular/bookmark
solid/bookmark
solid/box-open
+201
View File
@@ -0,0 +1,201 @@
/**
* Long-press as the touch equivalent of a right-click (plan 016 B2,
* phase 3).
*
* Every context menu in the app opens from a `contextmenu` event
* `track-list` and `queue-panel` delegate one on their virtualizer,
* the card grids and both playlist detail views bind one per row, and
* `explore-artist-details` binds three. A phone has no right-click, so
* a phone reached none of them.
*
* **This is one document listener, not six components' worth of touch
* handling.** A press that stays still for `LONG_PRESS_MS` dispatches a
* synthetic `contextmenu` at the touch point on the element the touch
* actually landed on, and every existing handler delegated or
* per-row, in any shadow root runs unchanged. Six implementations of
* a gesture is exactly the fault `ContextMenuController` exists to
* prevent, and a seam that needs no component to opt in cannot be
* forgotten by the next component.
*
* Three things about it are load-bearing.
*
* **The target comes from `composedPath()[0]`, not from
* `elementFromPoint`**, which stops at the outermost shadow host: every
* menu in this app is bound inside one, so a synthetic event dispatched
* on the host reaches a delegated listener and no per-row one.
*
* **A browser that already does this must win.** Chromium fires a
* `contextmenu` on long-press itself; WebKitGTK and the Android WebView
* vary. So one arriving during the press cancels ours, and one arriving
* just after ours is swallowed at document capture where nothing else
* has seen it yet. The two are told apart by **identity** (a `WeakSet`
* of the events this module made) rather than by `isTrusted`, so the
* suppressor cannot eat the event it exists to deliver, the rule holds
* for anything else in the app that synthesises one, and a test can
* stand in for a browser that fires its own.
*
* **The click that ends the gesture is swallowed.** A row's click
* selects, and a card's plays; without this, opening a menu also
* activates the thing under it. It is keyed on the gesture (cleared by
* the next `pointerdown`) rather than on a time window, so a quick tap
* on the menu that just opened is not eaten too.
*/
/** How long a press must hold still to mean "menu". */
export const LONG_PRESS_MS = 500;
/**
* How far a press may drift and still count. Below a finger's own
* jitter is a gesture nobody can perform; above ~12px it starts
* stealing the first frames of a scroll.
*/
export const MOVE_TOLERANCE_PX = 10;
/** The active installation, so a second call is a no-op rather than a
* second listener set. */
let uninstall: (() => void) | null = null;
/** The events this module dispatched. Identity, not `isTrusted`: see
* the note above. */
const ours = new WeakSet<Event>();
/**
* Install the gesture. Idempotent; returns the uninstaller (which the
* tests use the app installs once and never removes it).
*/
export function installLongPressContextMenu(): () => void {
if (uninstall) return uninstall;
let timer: ReturnType<typeof setTimeout> | null = null;
let originX = 0;
let originY = 0;
let target: EventTarget | null = null;
/** A trusted `contextmenu` arrived for this press: the browser has
* it covered. */
let nativeSeen = false;
/** We opened a menu, and the click ending that gesture is not a
* click on anything. */
let swallowClick = false;
/** We dispatched one, so a trusted one arriving now is a duplicate. */
let justFired = false;
const cancel = (): void => {
if (timer !== null) clearTimeout(timer);
timer = null;
target = null;
};
const fire = (): void => {
timer = null;
const el = target;
target = null;
if (nativeSeen || !el) return;
justFired = true;
swallowClick = true;
const menu = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
// Or it stops at the shadow root the row lives in, and the
// delegated listeners never see it.
composed: true,
clientX: originX,
clientY: originY,
button: 2,
});
ours.add(menu);
el.dispatchEvent(menu);
};
const onPointerDown = (e: PointerEvent): void => {
// A new gesture: whatever the last one left behind is stale.
swallowClick = false;
justFired = false;
nativeSeen = false;
cancel();
if (e.pointerType !== 'touch' || !e.isPrimary) return;
originX = e.clientX;
originY = e.clientY;
target = e.composedPath()[0] ?? e.target;
timer = setTimeout(fire, LONG_PRESS_MS);
};
const onPointerMove = (e: PointerEvent): void => {
if (timer === null) return;
const drifted =
Math.abs(e.clientX - originX) > MOVE_TOLERANCE_PX ||
Math.abs(e.clientY - originY) > MOVE_TOLERANCE_PX;
if (drifted) cancel();
};
const onContextMenu = (e: Event): void => {
// Ours. Everything below is about somebody else's.
if (ours.has(e)) return;
if (timer !== null) {
// The browser got there first, so stand down rather than
// opening the same menu twice.
nativeSeen = true;
cancel();
return;
}
if (justFired) {
justFired = false;
e.preventDefault();
e.stopImmediatePropagation();
}
};
const onClick = (e: Event): void => {
if (!swallowClick) return;
swallowClick = false;
e.preventDefault();
e.stopImmediatePropagation();
};
// Capture throughout: a component handler that stops propagation
// (every context-menu handler in the app does) must not be able to
// hide the gesture from this, and the suppressors have to run
// before anything that would act on the event.
const opts = { capture: true } as const;
document.addEventListener('pointerdown', onPointerDown, opts);
document.addEventListener('pointermove', onPointerMove, opts);
document.addEventListener('pointerup', cancel, opts);
document.addEventListener('pointercancel', cancel, opts);
document.addEventListener('contextmenu', onContextMenu, opts);
document.addEventListener('click', onClick, opts);
// A scroll started by something other than the finger (momentum, a
// programmatic reveal) still means the press was not a press.
document.addEventListener('scroll', cancel, { capture: true, passive: true });
uninstall = () => {
cancel();
document.removeEventListener('pointerdown', onPointerDown, opts);
document.removeEventListener('pointermove', onPointerMove, opts);
document.removeEventListener('pointerup', cancel, opts);
document.removeEventListener('pointercancel', cancel, opts);
document.removeEventListener('contextmenu', onContextMenu, opts);
document.removeEventListener('click', onClick, opts);
document.removeEventListener('scroll', cancel, opts);
uninstall = null;
};
return uninstall;
}
+87
View File
@@ -0,0 +1,87 @@
/**
* "Ask the user for a folder", once.
*
* Three call sites want a directory the first-run wizard, the library
* settings and the download clients' save path and on desktop all
* three can use the platform's own dialog. On Android that dialog
* *returns an error*: the Storage Access Framework yields tree URIs
* rather than filesystem paths, and a path is what this app's library
* model is keyed on.
*
* So the platform test lives here rather than at each call site, which
* is the same rule `utils/binding.ts` and `utils/library-status.ts`
* follow: a fact about the platform is stated once, at the boundary.
*
* *Which* platform is asked of the backend, not of the Wails runtime's
* `System.IsAndroid()`. The dialog is backend code, so the backend is
* what knows whether it can open one; it answers for iOS at the same
* time; and it keeps this testable through the ordinary transport fake
* rather than a module mock.
*
* Returns the chosen path, or `null` if the user cancelled. Callers
* treat those the same way they always did a falsy result means "no
* change" so adopting this is a one-line edit at each site.
*/
import {
DirectoryPicker,
HasNativeDirectoryPicker,
} from "@go/frontendutil/frontendutil.js";
import type { FolderPicker } from "../components/folder-picker/folder-picker";
let picker: FolderPicker | null = null;
let native: Promise<boolean> | null = null;
/**
* Asked once per session and remembered: it cannot change while the app
* is running, and a folder picker should not pay a round trip to find
* out which kind it is.
*/
function hasNative(): Promise<boolean> {
native ??= HasNativeDirectoryPicker().catch(() => true);
return native;
}
/** Testing seam: forget the cached platform answer. */
export function resetDirectoryPickerCache(): void {
native = null;
picker = null;
}
/**
* The in-app browser is mounted on first use and then kept.
*
* Mounting it on demand and awaiting its module in the same update as
* `showModal()` is the trap `index.ts` documents for views so the
* element is created, appended and *then* asked to open, on separate
* turns.
*/
async function inAppPicker(): Promise<FolderPicker> {
if (picker) return picker;
await import("../components/folder-picker/folder-picker");
const el = document.createElement("folder-picker");
document.body.appendChild(el);
picker = el;
return el;
}
/** Ask for a directory. Resolves to an absolute path, or null. */
export async function pickDirectory(startAt?: string): Promise<string | null> {
if (!(await hasNative())) {
const el = await inAppPicker();
return el.choose(startAt);
}
// The desktop dialog returns '' when dismissed; normalise that to
// null so every caller has one falsy case to handle rather than
// two.
const chosen = await DirectoryPicker();
return chosen === "" ? null : chosen;
}
+165
View File
@@ -0,0 +1,165 @@
/**
* The phone's primary navigation (plan 016 B2).
*
* Three of these are about the thing that makes a second nav dangerous:
* it has to agree with the first one. `bottom-nav` emits the same
* bubbling, composed `navigate` event `app-sidebar` does and listens
* for that event globally, so a navigation from anywhere a card, a
* detail view, the drawer's own sidebar moves its highlight too. A
* tab bar that only tracks its own clicks looks right until the moment
* the user arrives somewhere by another route.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import '@components/bottom-nav/bottom-nav';
import type { BottomNav } from '@components/bottom-nav/bottom-nav';
import { fixture, shadow, shadowAll, update } from '@test/support/render';
import { resetHarness } from '@test/support/harness';
type Nav = BottomNav;
const tabs = (el: HTMLElement) =>
shadowAll<HTMLButtonElement>(el, 'nav button');
/** Resolve on one occurrence of an event, or reject loudly on time. */
const once = (el: Element, name: string, timeoutMs = 2000) =>
new Promise<void>((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`${name} never fired`)),
timeoutMs,
);
el.addEventListener(name, () => {
clearTimeout(timer);
resolve();
}, { once: true });
});
describe('bottom-nav', () => {
beforeEach(() => {
resetHarness();
});
it('offers the four phone destinations and a way to the rest', async () => {
const el = await fixture<Nav>('bottom-nav');
expect(tabs(el).map((b) => b.dataset.testid)).toEqual([
'tab-home',
'tab-albums',
'tab-tracks',
'tab-playlists',
'tab-more',
]);
});
it('emits a navigate event that escapes its shadow root', async () => {
const el = await fixture<Nav>('bottom-nav');
const seen: string[] = [];
document.addEventListener('navigate', (e) => {
seen.push((e as CustomEvent<{ view: string }>).detail.view);
});
shadow<HTMLButtonElement>(el, '[data-testid="tab-albums"]')?.click();
// Composed and bubbling, or index.ts's document-level listener --
// the only thing that actually changes the view -- never hears it.
expect(seen).toEqual(['albums']);
});
it('follows a navigation it did not send', async () => {
const el = await fixture<Nav>('bottom-nav');
document.dispatchEvent(new CustomEvent('navigate', {
detail: { view: 'tracks' },
bubbles: true,
composed: true,
}));
await update(el, {});
const current = tabs(el)
.filter((b) => b.getAttribute('aria-current') === 'page')
.map((b) => b.dataset.testid);
expect(current).toEqual(['tab-tracks']);
});
it('marks exactly one tab current, and none for a view it has no tab for', async () => {
const el = await fixture<Nav>('bottom-nav');
document.dispatchEvent(new CustomEvent('navigate', {
detail: { view: 'settings' },
bubbles: true,
composed: true,
}));
await update(el, {});
// Settings lives in the drawer, so nothing in the bar is current.
// Leaving Home highlighted would be a tab bar lying about where
// the user is.
expect(
tabs(el).filter((b) => b.getAttribute('aria-current') === 'page'),
).toHaveLength(0);
});
it('closes the drawer when a navigation happens', async () => {
const el = await fixture<Nav>('bottom-nav');
const drawer = shadow<HTMLElement & { open: boolean }>(el, 'wa-drawer');
if (!drawer) throw new Error('no drawer');
// The drawer animates, so the assertion is its own event rather
// than the `open` property: setting `open = false` starts a hide
// that has not finished on the next microtask, and a test that
// reads the property in between sees the state it is leaving.
const shown = once(drawer, 'wa-after-show');
shadow<HTMLButtonElement>(el, '[data-testid="tab-more"]')?.click();
await shown;
const hidden = once(drawer, 'wa-after-hide');
document.dispatchEvent(new CustomEvent('navigate', {
detail: { view: 'settings' },
bubbles: true,
composed: true,
}));
await hidden;
expect(drawer.open).toBe(false);
});
it('gives every tab a name and a target big enough to hit', async () => {
const el = await fixture<Nav>('bottom-nav');
for (const button of tabs(el)) {
expect(button.textContent?.trim()).not.toBe('');
// 48px is the floor for a touch target; the bar is the one
// surface in this app that has no pointer to fall back on.
expect(button.getBoundingClientRect().height).toBeGreaterThanOrEqual(48);
}
});
it('holds no second sidebar until the drawer is asked for', async () => {
const el = await fixture<Nav>('bottom-nav');
// `app-sidebar` carries a data-testid per destination, so a spare
// copy standing by makes every `nav-*` testid ambiguous for the
// *whole app*: rendering it unconditionally failed 30 existing
// specs with "strict mode violation: resolved to 2 elements", on a
// desktop viewport where this element is not even visible.
expect(shadow(el, 'app-sidebar')).toBeNull();
});
it('keeps the drawer sidebar expanded, where there is room for labels', async () => {
const el = await fixture<Nav>('bottom-nav');
shadow<HTMLButtonElement>(el, '[data-testid="tab-more"]')?.click();
await update(el, {});
// Without this the sidebar's own auto-collapse (a response to a
// narrow *shell*) would render icons in a full-width drawer.
expect(shadow<HTMLElement>(el, 'app-sidebar')?.hasAttribute('expanded'))
.toBe(true);
});
});
@@ -0,0 +1,206 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { wails } from '../support/wails-fake';
import type { FolderPicker } from '@components/folder-picker/folder-picker';
const listings: Record<string, unknown> = {
'/storage/emulated/0': {
path: '/storage/emulated/0',
parent: '/storage/emulated',
entries: [
{ name: 'Music', path: '/storage/emulated/0/Music' },
{ name: 'Podcasts', path: '/storage/emulated/0/Podcasts' },
],
},
'/storage/emulated/0/Music': {
path: '/storage/emulated/0/Music',
parent: '/storage/emulated/0',
entries: [],
},
};
/**
* `pickDirectory` imports the picker's chunk before it can create the
* element, so the element does not exist on the turn the call is made.
* That is deliberate -- mounting a dialog and calling showModal() in
* one update is the trap `index.ts` documents -- so the test waits for
* it rather than assuming it is synchronous.
*/
async function host(): Promise<FolderPicker> {
for (let i = 0; i < 50; i++) {
const el = document.querySelector<FolderPicker>('folder-picker');
if (el) {
await el.updateComplete;
return el;
}
await new Promise((r) => setTimeout(r, 10));
}
throw new Error('folder-picker did not mount itself');
}
function click(el: FolderPicker, testid: string): void {
el.shadowRoot
?.querySelector<HTMLButtonElement>(`[data-testid="${testid}"]`)
?.click();
}
beforeEach(() => {
wails.stub('frontendutil.FrontendUtil.HasNativeDirectoryPicker', true);
wails.stub('frontendutil.FrontendUtil.DirectoryPicker', '/home/logan/Music');
wails.stub('frontendutil.FrontendUtil.DefaultBrowseRoot', '/storage/emulated/0');
wails.stub('frontendutil.FrontendUtil.ListDirectories', (path: string) => {
const listing = listings[path || '/storage/emulated/0'];
if (!listing) throw new Error('permission denied');
return listing;
});
});
afterEach(() => {
document.querySelector('folder-picker')?.remove();
wails.reset();
});
describe('pickDirectory', () => {
it('uses the platform dialog off Android', async () => {
const { pickDirectory, resetDirectoryPickerCache } = await import(
'@utils/pick-directory'
);
resetDirectoryPickerCache();
await expect(pickDirectory()).resolves.toBe('/home/logan/Music');
expect(document.querySelector('folder-picker')).toBeNull();
});
it('normalises the desktop dialog\u2019s empty string to null', async () => {
wails.stub('frontendutil.FrontendUtil.DirectoryPicker', '');
const { pickDirectory, resetDirectoryPickerCache } = await import(
'@utils/pick-directory'
);
resetDirectoryPickerCache();
await expect(pickDirectory()).resolves.toBeNull();
});
it('browses in-app on Android, and never opens the platform dialog', async () => {
wails.stub('frontendutil.FrontendUtil.HasNativeDirectoryPicker', false);
const { pickDirectory, resetDirectoryPickerCache } = await import(
'@utils/pick-directory'
);
resetDirectoryPickerCache();
const answer = pickDirectory();
const el = await host();
await new Promise((r) => setTimeout(r, 0));
await el.updateComplete;
click(el, 'folder-picker-select');
await expect(answer).resolves.toBe('/storage/emulated/0');
});
it('resolves null when the browser is cancelled', async () => {
wails.stub('frontendutil.FrontendUtil.HasNativeDirectoryPicker', false);
const { pickDirectory, resetDirectoryPickerCache } = await import(
'@utils/pick-directory'
);
resetDirectoryPickerCache();
const answer = pickDirectory();
const el = await host();
await new Promise((r) => setTimeout(r, 0));
await el.updateComplete;
el.shadowRoot
?.querySelectorAll<HTMLButtonElement>('.actions button')[0]
?.click();
await expect(answer).resolves.toBeNull();
});
it('descends into a folder and returns the one it is showing', async () => {
wails.stub('frontendutil.FrontendUtil.HasNativeDirectoryPicker', false);
const { pickDirectory, resetDirectoryPickerCache } = await import(
'@utils/pick-directory'
);
resetDirectoryPickerCache();
const answer = pickDirectory();
const el = await host();
await new Promise((r) => setTimeout(r, 0));
await el.updateComplete;
const music = [
...(el.shadowRoot?.querySelectorAll<HTMLButtonElement>(
'[data-testid="folder-picker-list"] button',
) ?? []),
].find((b) => b.textContent?.includes('Music'));
music?.click();
await new Promise((r) => setTimeout(r, 0));
await el.updateComplete;
click(el, 'folder-picker-select');
await expect(answer).resolves.toBe('/storage/emulated/0/Music');
});
/**
* A directory that cannot be read is not a failed picker. Android's
* storage root holds directories no app may enter, and stranding the
* user in an empty dialog with no way back is worse than saying so.
*/
it('stays put and explains when a folder cannot be opened', async () => {
wails.stub('frontendutil.FrontendUtil.HasNativeDirectoryPicker', false);
const { pickDirectory, resetDirectoryPickerCache } = await import(
'@utils/pick-directory'
);
resetDirectoryPickerCache();
const answer = pickDirectory();
const el = await host();
await new Promise((r) => setTimeout(r, 0));
await el.updateComplete;
// 'Podcasts' has no listing, so ListDirectories rejects.
const bad = [
...(el.shadowRoot?.querySelectorAll<HTMLButtonElement>(
'[data-testid="folder-picker-list"] button',
) ?? []),
].find((b) => b.textContent?.includes('Podcasts'));
bad?.click();
await new Promise((r) => setTimeout(r, 0));
await el.updateComplete;
expect(el.shadowRoot?.querySelector('[role="alert"]')).toBeTruthy();
expect(
el.shadowRoot?.querySelector('[data-testid="folder-picker-path"]')
?.textContent,
).toContain('/storage/emulated/0');
click(el, 'folder-picker-select');
await expect(answer).resolves.toBe('/storage/emulated/0');
});
});
+212
View File
@@ -0,0 +1,212 @@
/**
* Long-press as the touch route to a context menu (plan 016 B2 phase 3).
*
* These run in a real browser with real event dispatch, which is the
* only place the two things that make this hard are true: the synthetic
* event has to cross a shadow boundary to reach the listener a
* component actually bound, and the suppressors have to tell a trusted
* event from ours at document capture without eating the one they exist
* to deliver.
*
* The timings are real rather than faked, because the thing under test
* *is* a timing, and 600 ms twice is cheaper than a fake-timer harness
* that would also have to fake the pointer events.
*/
import { describe, expect, it, afterEach, beforeEach } from 'vitest';
import {
installLongPressContextMenu,
LONG_PRESS_MS,
MOVE_TOLERANCE_PX,
} from '@utils/long-press';
/** A press that has certainly resolved, either way. */
const HELD = LONG_PRESS_MS + 120;
/** A press that has certainly not. */
const BRIEF = Math.round(LONG_PRESS_MS / 4);
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
let uninstall: (() => void) | null = null;
let host: HTMLElement;
let inner: HTMLElement;
/** A row inside a shadow root, which is where every menu in this app
* is bound an element in the light DOM would pass a weaker test. */
function mountRow(): { host: HTMLElement; inner: HTMLElement } {
const el = document.createElement('div');
const root = el.attachShadow({ mode: 'open' });
const row = document.createElement('div');
row.textContent = 'a track';
root.append(row);
document.body.append(el);
return { host: el, inner: row };
}
function press(
el: EventTarget,
type: string,
init: PointerEventInit = {},
): void {
el.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
pointerType: 'touch',
isPrimary: true,
clientX: 40,
clientY: 60,
...init,
}),
);
}
/**
* Record every `contextmenu` that reaches the listener, *as the
* listener sees it*.
*
* `target` is retargeted for the scope reading it, so an assertion made
* after dispatch has finished reports the shadow host however the event
* was dispatched - which is the same answer a broken implementation
* gives. It has to be read from inside the handler, where the component
* reads it.
*/
function recordMenus(el: EventTarget): { event: MouseEvent; target: EventTarget | null }[] {
const seen: { event: MouseEvent; target: EventTarget | null }[] = [];
el.addEventListener('contextmenu', (e) => {
e.preventDefault();
// Every real handler does this; the gesture must work anyway.
e.stopPropagation();
seen.push({ event: e as MouseEvent, target: e.target });
});
return seen;
}
describe('long-press opens a context menu', () => {
beforeEach(() => {
uninstall = installLongPressContextMenu();
({ host, inner } = mountRow());
});
afterEach(() => {
uninstall?.();
uninstall = null;
host.remove();
});
it('dispatches one at the touch point, on the element touched', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
await wait(HELD);
expect(seen).toHaveLength(1);
expect(seen[0]?.event.clientX).toBe(40);
expect(seen[0]?.event.clientY).toBe(60);
// Dispatched on the row itself, not on its shadow host - which is
// the difference between a per-row handler firing and only a
// delegated one firing.
expect(seen[0]?.target).toBe(inner);
});
it('is cancelled by a press that moves', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
press(inner, 'pointermove', {
clientX: 40 + MOVE_TOLERANCE_PX + 5,
clientY: 60,
});
await wait(HELD);
expect(seen).toHaveLength(0);
});
it('tolerates the jitter a finger cannot help', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
press(inner, 'pointermove', { clientX: 43, clientY: 62 });
await wait(HELD);
expect(seen).toHaveLength(1);
});
it('is cancelled by lifting early, and by a scroll', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
await wait(BRIEF);
press(inner, 'pointerup');
await wait(HELD);
expect(seen).toHaveLength(0);
press(inner, 'pointerdown');
press(inner, 'pointercancel');
await wait(HELD);
expect(seen).toHaveLength(0);
});
it('ignores a mouse, which has a right button of its own', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown', { pointerType: 'mouse' });
await wait(HELD);
expect(seen).toHaveLength(0);
});
it('swallows the click that ends the gesture, and only that one', async () => {
let clicks = 0;
inner.addEventListener('click', () => {
clicks += 1;
});
press(inner, 'pointerdown');
await wait(HELD);
press(inner, 'pointerup');
inner.click();
expect(clicks).toBe(0);
// The next tap is a tap: on a phone that is the user choosing an
// item in the menu that just opened, so eating it would make the
// gesture useless.
press(inner, 'pointerdown');
press(inner, 'pointerup');
inner.click();
expect(clicks).toBe(1);
});
it('stands down where the browser fires its own', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
await wait(BRIEF);
// Chromium does this itself on touch; WebKit and the Android
// WebView vary, which is the whole reason both halves exist. A
// test cannot dispatch a *trusted* event, which is why the module
// tells its own apart by identity rather than by `isTrusted`.
inner.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
composed: true,
cancelable: true,
}),
);
await wait(HELD);
// One menu: the browser's. Not two.
expect(seen).toHaveLength(1);
});
});
@@ -0,0 +1,126 @@
/**
* The full-screen now-playing view (plan 016 B2, phase 2).
*
* What is worth pinning here is not the layout but the *composition*:
* it renders the same `<seek-bar>`, `<player-controls>` and
* `<volume-control>` the desktop transport does, rather than its own.
* A phone layout that reimplements the transport is a second transport
* to fix every bug in and the seek bar in particular carries
* interpolation rules that took a plan of their own to get right.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import '@components/now-playing-view/now-playing-view';
import { Events } from '../../src/events';
import { emit, resetHarness, stub } from '@test/support/harness';
import { fixture, shadow, text } from '@test/support/render';
import type { TrackInfo } from '@store/player-store';
const TRACK: TrackInfo = {
fileName: 'tideline.mp3',
filePath: '/music/tideline.mp3',
trackLength: 245,
seekPosition: 0,
state: 'playing',
title: 'Tideline',
artist: 'Sea Change',
album: 'Ebb',
coverArt: '/covers/ebb.jpg',
coverArtSmall: '/covers/ebb_sm.jpg',
coverArtMedium: '/covers/ebb_md.jpg',
coverArtLarge: '/covers/ebb_lg.jpg',
trackChangeId: 1,
artistMbid: '',
releaseGroupMbid: '',
recordingMbid: '',
};
describe('now-playing-view', () => {
beforeEach(() => {
resetHarness();
});
it('reuses the real transport components', async () => {
emit(Events.TrackChanged, TRACK);
const el = await fixture('now-playing-view');
for (const tag of ['seek-bar', 'player-controls', 'volume-control']) {
expect(shadow(el, tag), `${tag} is not rendered`).not.toBeNull();
}
});
it('shows the track, and the largest cover tier that is kept', async () => {
emit(Events.TrackChanged, TRACK);
const el = await fixture('now-playing-view');
expect(text(el, '[data-testid="npv-title"]')).toBe('Tideline');
// `saveCoverArt` records the largest *tier* as the path; there is
// no full-resolution original on disk to reach for.
expect(
shadow<HTMLImageElement>(el, '[data-testid="npv-art"]')?.getAttribute('src'),
).toBe('/covers/ebb_lg.jpg');
});
it('says so when nothing is playing, rather than rendering an empty frame', async () => {
// The player store is a singleton and outlives a test, so "no
// track" has to be stated rather than assumed from a fresh mount.
emit(Events.TrackChanged, null);
const el = await fixture('now-playing-view');
expect(shadow(el, '[data-testid="npv-empty"]')).not.toBeNull();
expect(shadow(el, '[data-testid="npv-art"]')).toBeNull();
// …and the way out is still there, which is the whole point of
// rendering the header in both branches.
expect(shadow(el, '[data-testid="npv-back"]')).not.toBeNull();
});
it('leaves by the nav stack, not by guessing where it came from', async () => {
emit(Events.TrackChanged, TRACK);
const el = await fixture('now-playing-view');
let backs = 0;
document.addEventListener('navigate-back', () => {
backs += 1;
});
shadow<HTMLButtonElement>(el, '[data-testid="npv-back"]')?.click();
// `navigate-back` pops what index.ts pushed. Dispatching a
// `navigate` to a hardcoded view would strand anyone who arrived
// here from a detail page.
expect(backs).toBe(1);
});
it('gives the favourite button a target and a state', async () => {
stub('playlist.Service.ToggleFavorite', undefined);
emit(Events.TrackChanged, TRACK);
const el = await fixture('now-playing-view');
const fav = shadow<HTMLButtonElement>(el, '[data-testid="npv-favorite"]');
expect(fav).not.toBeNull();
expect(fav?.getAttribute('aria-pressed')).toBe('false');
// A button that says only "heart" says nothing; the name carries
// the track and the playlist it goes to.
expect(fav?.getAttribute('aria-label')).toContain('Tideline');
expect(fav!.getBoundingClientRect().height).toBeGreaterThanOrEqual(48);
});
it('gives the way out a thumb-sized target', async () => {
emit(Events.TrackChanged, TRACK);
const el = await fixture('now-playing-view');
const back = shadow<HTMLButtonElement>(el, '[data-testid="npv-back"]');
expect(back!.getBoundingClientRect().height).toBeGreaterThanOrEqual(48);
expect(back?.getAttribute('aria-label')).toBe('Back');
});
});
+15
View File
@@ -15,6 +15,7 @@ import (
"yellowjacket/backend/assets"
"yellowjacket/backend/config"
"yellowjacket/backend/profiling"
"yellowjacket/backend/system"
"yellowjacket/internal/dev"
)
@@ -28,6 +29,20 @@ var (
var frontendDistAssets embed.FS
func main() {
// **Mobile has no home directory, and this must run before anything
// asks for a path.** backend/system resolves config and data from
// $HOME or the OS equivalent, and on Android there is neither: its
// switch on runtime.GOOS took the default branch and returned
// errUnsupportedOS, so NewYellowJacketApp failed and main() exited
// six milliseconds after the JNI bridge came up -- with no panic and
// no tombstone, because os.Exit is not a crash.
//
// StoragePath() is the platform's own answer (getFilesDir() on
// Android, Application Support on iOS) and returns "" on desktop,
// where UseHomeOverride is then a no-op -- so this needs no build
// tag and changes nothing off mobile.
system.UseHomeOverride(application.Mobile.StoragePath())
// WebKitGTK's DMABuf renderer crashes on NVIDIA GPUs under Wayland.
// Only disable it for that specific combo so AMD/Intel and X11 users
// keep full hardware-accelerated buffer sharing. Users can also
+389
View File
@@ -0,0 +1,389 @@
#!/usr/bin/env bash
#
# The Android tier: an emulator, an APK, and a way to find out why the
# app died.
#
# This is the phone equivalent of `dev-headless.sh`, and it is
# deliberately shaped like it — start in the background and return,
# stop by saved state, tail a log — because the operating pattern is
# the one this repo already has. What is different is *what a failure
# looks like*, and that is the whole reason this script exists rather
# than a paragraph telling you to run adb.
#
# **Go's stdout does not reach logcat.** An Android app's fd 1 and 2 go
# to /dev/null, so every `slog` line the app writes — including the one
# naming the error it is about to exit on — is discarded. There is no
# flag for this: `setprop log.redirect-stdio true` redirects the *Java*
# runtime's System.out and does nothing for a c-shared Go library.
#
# **And `os.Exit` is a silent death.** `main()` ends several failure
# paths in `os.Exit(1)`; from Android's side that is a process that
# vanished, reported as "has died: fg TOP" and signal 9, with no panic,
# no `AndroidRuntime` stack and no tombstone — the three places anyone
# would look. ActivityManager then restarts it, so `pidof` answers with
# a pid and the app looks alive while crash-looping several times a
# second.
#
# `smoke` exists because of those two facts together: the honest test
# is not "did it start" but "is the same pid still there a few seconds
# later", and the useful output is the app's own logcat tags plus a
# named guess at which `os.Exit` it took.
set -euo pipefail
cd "$(dirname "$0")/.."
AVD="${YJ_AVD:-yj-test}"
SDK="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-$HOME/Android/Sdk}}"
PKG="${YJ_ANDROID_PKG:-app.yellowjacket}"
# Where `make android-inspect` forwards the WebView's devtools socket.
CDP_PORT="${YJ_ANDROID_CDP_PORT:-9222}"
# **Not "$PKG/.MainActivity".** A leading-dot activity is resolved
# relative to the *applicationId*, and the scaffold's activity lives in
# the Java package `com.wails.app`, which is deliberately not the
# applicationId (see app/build.gradle). The short form silently
# resolves to app.yellowjacket.MainActivity, which does not exist, and
# `am start` fails with a class-not-found that reads like a broken
# build rather than a wrong name.
ACTIVITY="${YJ_ANDROID_ACTIVITY:-com.wails.app.MainActivity}"
IMAGE="${YJ_ANDROID_IMAGE:-system-images;android-35;google_apis;x86_64}"
DEVDIR=".dev"
PIDFILE="$DEVDIR/emulator.pid"
LOGFILE="$DEVDIR/emulator.log"
ADB="$SDK/platform-tools/adb"
EMULATOR="$SDK/emulator/emulator"
SDKMANAGER="$SDK/cmdline-tools/latest/bin/sdkmanager"
AVDMANAGER="$SDK/cmdline-tools/latest/bin/avdmanager"
die() { echo "android: $*" >&2; exit 1; }
need_sdk() {
[ -x "$ADB" ] || die "no adb at $ADB — set ANDROID_SDK_ROOT, or run 'make android-setup'"
[ -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
# the same trap dev-stop.sh documents.
running() {
[ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null
}
cmd_setup() {
[ -x "$SDKMANAGER" ] || die "no sdkmanager at $SDKMANAGER; install the Android command line tools first"
# Each piece is installed only when missing. sdkmanager is itself
# idempotent but still spends minutes verifying, so the guards are
# what make this cheap to re-run.
for want in "platform-tools" "platforms;android-35" "build-tools;34.0.0" "$IMAGE"; do
dir="$SDK/$(printf '%s' "$want" | tr ';' '/')"
if [ -d "$dir" ]; then
echo " $want: present"
else
echo " $want: installing"
yes | "$SDKMANAGER" --install "$want" >/dev/null
fi
done
if "$EMULATOR" -list-avds 2>/dev/null | grep -qx "$AVD"; then
echo " avd $AVD: present"
else
echo " avd $AVD: creating"
echo no | "$AVDMANAGER" create avd -n "$AVD" -k "$IMAGE" -d pixel_6 --force >/dev/null
fi
# A dependency with a requirement, checked like one. Without KVM the
# emulator falls back to full software emulation and a boot that
# takes 30 s takes 20 minutes — which reads as a hung target.
if ! "$EMULATOR" -accel-check 2>&1 | grep -q "is installed and usable"; then
echo
echo " WARNING: KVM is not usable. The emulator will run under software"
echo " emulation and boot times go from ~30s to tens of minutes."
echo " Check /dev/kvm exists and that you are in the kvm group."
fi
}
cmd_start() {
need_sdk
mkdir -p "$DEVDIR"
if running; then
echo "emulator already running (pid $(cat "$PIDFILE"))"
else
"$EMULATOR" -list-avds 2>/dev/null | grep -qx "$AVD" ||
die "no AVD named '$AVD' — run 'make android-setup'"
# -no-window because there is no display and does not need one;
# -no-snapshot so a run starts from the same state every time,
# which is what makes a smoke result mean something.
nohup "$EMULATOR" -avd "$AVD" \
-no-window -no-boot-anim -no-snapshot \
-gpu swiftshader_indirect \
-netdelay none -netspeed full \
>"$LOGFILE" 2>&1 &
echo $! >"$PIDFILE"
echo "emulator starting (pid $(cat "$PIDFILE")), log: $LOGFILE"
fi
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"
"$ADB" shell getprop ro.build.version.release |
sed 's/^/ android /'
return 0
fi
echo -n .
sleep 2
done
echo
die "boot did not complete in 300s; see $LOGFILE"
}
cmd_stop() {
if running; then
pid=$(cat "$PIDFILE")
# The emulator's own console command shuts the guest down
# cleanly; the saved pid is the fallback and the guarantee.
"$ADB" emu kill >/dev/null 2>&1 || true
for _ in $(seq 1 15); do
kill -0 "$pid" 2>/dev/null || break
sleep 1
done
kill -0 "$pid" 2>/dev/null && kill "$pid" 2>/dev/null || true
echo "emulator stopped"
else
echo "emulator not running"
fi
rm -f "$PIDFILE"
}
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
# build, and neither error says what to do about it.
#
# A *downgrade* is the versionCode rule working as designed: a bare
# `make android` produces versionCode 1, so it will not install over
# anything a versioned build left behind. A *signature* mismatch is
# the rule this whole pipeline exists for — a debug-signed local
# build cannot replace a release-signed one.
#
# Both are fixed by uninstalling, and on a throwaway emulator that
# costs nothing, so say so rather than making someone read the
# constant name.
out=$("$ADB" install -r bin/yellowjacket.apk 2>&1) || {
printf '%s\n' "$out"
case "$out" in
*INSTALL_FAILED_VERSION_DOWNGRADE*)
echo
echo "The installed copy has a higher versionCode than this build."
echo "A bare 'make android' builds versionCode 1; a versioned one"
echo "builds e.g. 10301. Either uninstall:"
echo " $ADB uninstall $PKG"
echo "or build with a version:"
echo " YJ_VERSION=1.3.1 YJ_VERSION_CODE=10301 make android"
;;
# 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"
echo "refuses to publish a debug-signed APK. Uninstall:"
echo " $ADB uninstall $PKG"
;;
esac
return 1
}
printf '%s\n' "$out"
}
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
}
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.
"$ADB" logcat -v time \
WailsBridge:V "$PKG":V GoLog:V AndroidRuntime:E DEBUG:V libc:F ActivityManager:I '*:S'
}
# Forward the WebView's devtools socket, so the page can be asked things.
#
# Only a `debuggable` build opens that socket, and a debug build carries
# `applicationIdSuffix ".dev"` precisely so it can be installed *beside*
# the release app: the two are signed by different certificates, and
# Android's remedy for a certificate change is an uninstall, which takes
# the user's library with it. So this looks for the sibling first and the
# release id second.
#
# The socket name carries the pid, which changes on every launch -- which
# is why this resolves it rather than documenting a number.
cmd_inspect() {
need_sdk
pick_device || die "no device -- plug a phone in (USB debugging on) or run 'make android-emulator'"
local pkg pid
pid=""
for pkg in "$PKG.dev" "$PKG"; do
pid=$("$ADB" shell pidof "$pkg" 2>/dev/null | tr -d '\r' | awk '{print $1}')
[ -n "$pid" ] && break
done
[ -n "$pid" ] || die "neither $PKG.dev nor $PKG is running; launch it first"
"$ADB" forward --remove-all >/dev/null 2>&1 || true
"$ADB" forward "tcp:$CDP_PORT" "localabstract:webview_devtools_remote_$pid" >/dev/null \
|| die "adb forward failed"
echo "android: $pkg (pid $pid) devtools on http://localhost:$CDP_PORT"
echo " make android-eval EXPR='JSON.stringify({vp:[innerWidth,innerHeight]})'"
echo " (a release build has no devtools socket: build and install the debug one)"
}
# What the phone is actually showing.
#
# This tier exists because no other one can see the platform: system
# bars, the safe area, the keyboard, an OEM's permission dialog. All of
# those are things you have to *look* at, and two of the three faults
# found so far were found by reading a picture rather than an assertion
# (`android-tier.md`).
#
# `exec-out` and not `shell`: `adb shell` runs the output through a pty
# on some platforms, which translates LF and corrupts the PNG -- for
# which the symptom is an image viewer refusing a file that downloaded
# perfectly.
cmd_screenshot() {
need_sdk
pick_device || die "no device \u2014 plug a phone in (USB debugging on) or run 'make android-emulator'"
local out="${1:-}"
[ -n "$out" ] || out=".dev/android-$(date +%Y%m%d-%H%M%S).png"
mkdir -p "$(dirname "$out")"
"$ADB" exec-out screencap -p > "$out" || die "screencap failed"
[ -s "$out" ] || die "screencap produced nothing (is the screen locked?)"
echo "android: screenshot -> $out"
}
# Start the app and assert it is *still the same process* a few seconds
# later. "It started" is not the question — a crash-looping app starts
# continuously.
cmd_smoke() {
need_sdk
local wait_s="${1:-10}"
cmd_launch
sleep 3
# **`|| true` is load-bearing.** `pidof` exits 1 when it finds
# nothing, and under `set -e` a failing command substitution kills
# the script -- silently, before it can print why. That is invisible
# for as long as the app crash-*loops*, because there is always some
# pid; it appears the moment the app dies for good and ActivityManager
# stops respawning it, which is exactly the run you most want output
# from.
local first second
first=$("$ADB" shell pidof "$PKG" 2>/dev/null | tr -d '\r' | awk '{print $1}' || true)
sleep "$wait_s"
second=$("$ADB" shell pidof "$PKG" 2>/dev/null | tr -d '\r' | awk '{print $1}' || true)
if [ -n "$first" ] && [ "$first" = "$second" ]; then
echo "PASS: $PKG alive as pid $first after ${wait_s}s"
return 0
fi
echo "FAIL: $PKG is not stable (pid was '${first:-none}', now '${second:-none}')"
if [ -n "$first" ] && [ -n "$second" ]; then
echo " The pid changed: it is crash-looping, not running."
fi
echo
echo "--- last 40 app-relevant logcat lines ---"
"$ADB" logcat -d -v time \
WailsBridge:V "$PKG":V GoLog:V AndroidRuntime:E DEBUG:V libc:F '*:S' 2>/dev/null |
tail -40
echo
echo "--- reading this ---"
echo "If the last line is 'Wails bridge initialized' and nothing follows,"
echo "the Go side reached main() and left it. There will be no panic and"
echo "no tombstone, because that is os.Exit, not a crash. Go's stdout"
echo "does not reach logcat, so the slog line naming the error is gone."
echo "Work backwards through main()'s os.Exit(1) paths instead."
return 1
}
case "${1:-}" in
setup) cmd_setup ;;
start) cmd_start ;;
stop) cmd_stop ;;
install) cmd_install ;;
launch) cmd_launch ;;
logs) cmd_logs ;;
screenshot) cmd_screenshot "${2:-}" ;;
inspect) cmd_inspect ;;
smoke) cmd_smoke "${2:-10}" ;;
*)
echo "usage: $0 {setup|start|stop|install|launch|logs|screenshot [path]|inspect|smoke [seconds]}" >&2
exit 2
;;
esac
+97
View File
@@ -0,0 +1,97 @@
/**
* Evaluate an expression inside the app's WebView on a real device.
*
* The device tier could only ever *look* at the app (a screenshot) or
* read what Go chose to log. This is the third thing: the page's own
* answer, from the engine that is actually rendering it which is how
* "the icons are missing" stops being a guess about assets and becomes a
* computed style.
*
* Two facts make it work at all. A `debuggable` build calls
* `WebView.setWebContentsDebuggingEnabled(true)`, which opens an abstract
* unix socket per process (`webview_devtools_remote_<pid>`); `make
* android-inspect` forwards it to localhost. And **Playwright cannot use
* it** `connectOverCDP` immediately calls `Browser.setDownloadBehavior`,
* which a WebView answers with "Browser context management is not
* supported", so the connection dies before the first evaluate. Raw CDP
* over Node's built-in WebSocket is a dozen lines and has no such
* opinion.
*
* Usage: node scripts/android-eval.mjs '<js expression>'
* make android-eval EXPR='...'
*
* The expression is evaluated with `awaitPromise`, so an async probe is
* fine. Return a string (`JSON.stringify(...)`) for anything structured:
* `returnByValue` will not serialise a DOM node.
*/
const PORT = process.env.YJ_ANDROID_CDP_PORT ?? '9222';
const expression = process.argv[2];
if (!expression) {
console.error("usage: node scripts/android-eval.mjs '<js expression>'");
process.exit(2);
}
const endpoint = `http://localhost:${PORT}/json`;
let targets;
try {
targets = await (await fetch(endpoint)).json();
} catch (err) {
console.error(
`android-eval: nothing on :${PORT} (${err.message})\n` +
" run 'make android-inspect' first, and check the phone is " +
'awake -- wireless adb drops when the screen sleeps',
);
process.exit(1);
}
const page = targets.find((t) => t.type === 'page');
if (!page) {
console.error('android-eval: no page target; is the app in the foreground?');
process.exit(1);
}
const ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise((resolve, reject) => {
ws.onopen = resolve;
ws.onerror = () => reject(new Error('websocket refused'));
});
const answer = await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('evaluate timed out')), 20_000);
ws.onmessage = (m) => {
const msg = JSON.parse(m.data);
if (msg.id !== 1) return;
clearTimeout(timer);
resolve(msg.result);
};
ws.send(
JSON.stringify({
id: 1,
method: 'Runtime.evaluate',
params: { expression, awaitPromise: true, returnByValue: true },
}),
);
});
ws.close();
if (answer.exceptionDetails) {
console.error(
'android-eval: threw:',
answer.exceptionDetails.exception?.description ??
answer.exceptionDetails.text,
);
process.exit(1);
}
const value = answer.result?.value;
console.log(typeof value === 'string' ? value : JSON.stringify(value, null, 2));
+15
View File
@@ -157,7 +157,22 @@ fi
# YJ_TESTCTL mounts backend/testctl's /__test/ endpoints. It is opt-in
# rather than implied by the dev build so that a human's `make dev` does
# not carry an arbitrary-SQL endpoint on a listening port.
#
# YJ_CORE_INDEX_URL points at a dead address, which is what
# seed-sandbox.sh and ci.yml already do and what this script was the
# only one *not* doing. Without it the app downloads and builds the
# real ~1M-row Explore catalog into the run's YJ_HOME, so a local `make
# e2e` runs against a different world than CI: the specs that stage
# their own catalog rows (requested-badge) then search a catalog full
# of real albums, fail to find their fixture, and report it as a
# regression in whatever was last changed. A spec tier whose result
# depends on what a previous run downloaded is not a result -- the same
# rule as the emulator's `-no-snapshot`.
#
# Set YJ_CORE_INDEX_URL yourself to opt back in, for exploring Explore
# by hand.
YJ_TESTCTL=1 \
YJ_CORE_INDEX_URL="${YJ_CORE_INDEX_URL:-http://127.0.0.1:1/none.tar.zst}" \
WAILS_SERVER_PORT="$PORT" \
YJ_LOG_LEVEL="$LOG_LEVEL" setsid dbus-run-session -- \
"$BIN" \