diff --git a/.gitea/workflows/android-apk.yml b/.gitea/workflows/android-apk.yml new file mode 100644 index 0000000..f91c042 --- /dev/null +++ b/.gitea/workflows/android-apk.yml @@ -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 + # /api/packages//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 <&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 ''" >&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" diff --git a/.gitignore b/.gitignore index 750e605..f9b2c5e 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.golangci.yml b/.golangci.yml index 68516b8..77edcb6 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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 diff --git a/.pi/skills/yellowjacket-dev/SKILL.md b/.pi/skills/yellowjacket-dev/SKILL.md index 56a2b47..c3b738f 100644 --- a/.pi/skills/yellowjacket-dev/SKILL.md +++ b/.pi/skills/yellowjacket-dev/SKILL.md @@ -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=` 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. diff --git a/.pi/skills/yellowjacket-dev/references/android-tier.md b/.pi/skills/yellowjacket-dev/references/android-tier.md new file mode 100644 index 0000000..01804fa --- /dev/null +++ b/.pi/skills/yellowjacket-dev/references/android-tier.md @@ -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. diff --git a/.planning/NOTES.md b/.planning/NOTES.md index e934304..bc5b1e3 100644 --- a/.planning/NOTES.md +++ b/.planning/NOTES.md @@ -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* `` 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_`, 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 `` 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. diff --git a/.planning/plans/active/015-android-release-pipeline.md b/.planning/plans/active/015-android-release-pipeline.md new file mode 100644 index 0000000..e26ed59 --- /dev/null +++ b/.planning/plans/active/015-android-release-pipeline.md @@ -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 1–2 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. diff --git a/.planning/plans/pending/016-android-feature-parity.md b/.planning/plans/pending/016-android-feature-parity.md new file mode 100644 index 0000000..4e499d6 --- /dev/null +++ b/.planning/plans/pending/016-android-feature-parity.md @@ -0,0 +1,389 @@ +# 016 — What Android parity would actually take + +> **Status: all of section A is done.** A1–A3 landed with "let the app +> reach the user's music"; A4 (MediaSession, transport notification, +> audio focus) landed with "survive the screen locking". The direction +> taken is **option 1, the full librarian**: `MANAGE_EXTERNAL_STORAGE` +> plus an in-app folder browser, which keeps the path-keyed model +> intact. B1/B2 remain, both awaiting a decision rather than work. The +> sections below are kept as written, because they are the argument the +> decision rests on — see "What is left" at the end for the current +> state. + +Plan 015 shipped a *pipeline*: the app cross-compiles, is signed and +versioned, and publishes from CI. This is the assessment of what stands +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 +`` 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 ~360–430 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 `` +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, + `` 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. diff --git a/CLAUDE.md b/CLAUDE.md index bb4889d..2847bc8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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, `` 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* `` 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. + +**`` 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 ``, `` and ``** 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. diff --git a/Makefile b/Makefile index c8409e5..1b4defc 100644 --- a/Makefile +++ b/Makefile @@ -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=) + @$(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=) + @$(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 diff --git a/Taskfile.yml b/Taskfile.yml index e32e485..c8ce242 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -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: diff --git a/backend/app.go b/backend/app.go index 1e3e8bc..9a25996 100644 --- a/backend/app.go +++ b/backend/app.go @@ -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", diff --git a/backend/frontendutil/browse.go b/backend/frontendutil/browse.go new file mode 100644 index 0000000..062598f --- /dev/null +++ b/backend/frontendutil/browse.go @@ -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 + } +} diff --git a/backend/frontendutil/browse_test.go b/backend/frontendutil/browse_test.go new file mode 100644 index 0000000..dce1993 --- /dev/null +++ b/backend/frontendutil/browse_test.go @@ -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) + } +} diff --git a/backend/mediacontrols/android.go b/backend/mediacontrols/android.go new file mode 100644 index 0000000..a19c2e0 --- /dev/null +++ b/backend/mediacontrols/android.go @@ -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) +} diff --git a/backend/mediacontrols/androidpayload.go b/backend/mediacontrols/androidpayload.go new file mode 100644 index 0000000..8985772 --- /dev/null +++ b/backend/mediacontrols/androidpayload.go @@ -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 +} diff --git a/backend/mediacontrols/androidpayload_test.go b/backend/mediacontrols/androidpayload_test.go new file mode 100644 index 0000000..314a154 --- /dev/null +++ b/backend/mediacontrols/androidpayload_test.go @@ -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) + } + } +} diff --git a/backend/mediacontrols/mediacontrols.go b/backend/mediacontrols/mediacontrols.go index ae7f5d6..7df9c8a 100644 --- a/backend/mediacontrols/mediacontrols.go +++ b/backend/mediacontrols/mediacontrols.go @@ -34,6 +34,13 @@ type Callbacks struct { OnPrevious func() OnSeek func(positionSec int) OnVolume func(volume float64) // 0.0–1.0 linear scale. + + // OnDuck asks for playback to be attenuated (true) or restored + // (false) without changing the user's volume. Android alone sends + // it, and only below API 26 -- from Oreo the audio framework ducks + // the app itself and reports no such focus change, so doing both + // would attenuate twice. + OnDuck func(ducked bool) } // Handler manages the OS media control integration. diff --git a/backend/mediacontrols/mpris_linux.go b/backend/mediacontrols/mpris_linux.go index ee3cd9d..b74f6d2 100644 --- a/backend/mediacontrols/mpris_linux.go +++ b/backend/mediacontrols/mpris_linux.go @@ -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 diff --git a/backend/mediacontrols/stub.go b/backend/mediacontrols/stub.go index 0eccd12..d1739a0 100644 --- a/backend/mediacontrols/stub.go +++ b/backend/mediacontrols/stub.go @@ -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" diff --git a/backend/player/player.go b/backend/player/player.go index d35e14b..b141a6d 100644 --- a/backend/player/player.go +++ b/backend/player/player.go @@ -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. diff --git a/backend/player/volume.go b/backend/player/volume.go index 5f2a002..e9cd0b0 100644 --- a/backend/player/volume.go +++ b/backend/player/volume.go @@ -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 diff --git a/backend/player/volume_test.go b/backend/player/volume_test.go index b887a28..423e1c7 100644 --- a/backend/player/volume_test.go +++ b/backend/player/volume_test.go @@ -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) + } +} diff --git a/backend/system/userdata.go b/backend/system/userdata.go index 39dde59..9f232fe 100644 --- a/backend/system/userdata.go +++ b/backend/system/userdata.go @@ -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) diff --git a/backend/system/userdata_test.go b/backend/system/userdata_test.go index b24abc0..3829360 100644 --- a/backend/system/userdata_test.go +++ b/backend/system/userdata_test.go @@ -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) + } + }) + } +} diff --git a/build/android/Taskfile.yml b/build/android/Taskfile.yml new file mode 100644 index 0000000..b157ca3 --- /dev/null +++ b/build/android/Taskfile.yml @@ -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//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 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= 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= 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 diff --git a/build/android/app/build.gradle b/build/android/app/build.gradle new file mode 100644 index 0000000..1343a81 --- /dev/null +++ b/build/android/app/build.gradle @@ -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' +} diff --git a/build/android/app/proguard-rules.pro b/build/android/app/proguard-rules.pro new file mode 100644 index 0000000..8b88c3d --- /dev/null +++ b/build/android/app/proguard-rules.pro @@ -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 ; +} + +# Keep Wails bridge classes +-keep class com.wails.app.WailsBridge { *; } +-keep class com.wails.app.WailsJSBridge { *; } diff --git a/build/android/app/src/main/AndroidManifest.xml b/build/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c84d187 --- /dev/null +++ b/build/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build/android/app/src/main/java/com/wails/app/MainActivity.java b/build/android/app/src/main/java/com/wails/app/MainActivity.java new file mode 100644 index 0000000..0840ded --- /dev/null +++ b/build/android/app/src/main/java/com/wails/app/MainActivity.java @@ -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. + * + *

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(). + * + *

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. + * + *

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 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

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". + * + *

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. + * + *

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(); + } + } +} diff --git a/build/android/app/src/main/java/com/wails/app/WailsBridge.java b/build/android/app/src/main/java/com/wails/app/WailsBridge.java new file mode 100644 index 0000000..6198770 --- /dev/null +++ b/build/android/app/src/main/java/com/wails/app/WailsBridge.java @@ -0,0 +1,1374 @@ +package com.wails.app; + +import android.app.Activity; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.ActivityInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.IntentFilter; +import android.content.res.Configuration; +import android.graphics.Rect; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; +import android.hardware.camera2.CameraCharacteristics; +import android.hardware.camera2.CameraManager; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.Uri; +import android.os.BatteryManager; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.os.PowerManager; +import android.os.StatFs; +import android.os.VibrationEffect; +import android.os.Vibrator; +import android.provider.Settings; +import android.speech.tts.TextToSpeech; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.View; +import android.view.WindowInsets; +import android.view.WindowInsetsController; +import android.view.WindowManager; +import android.webkit.WebView; +import android.widget.Toast; + +import androidx.appcompat.app.AlertDialog; +import androidx.biometric.BiometricManager; +import androidx.biometric.BiometricPrompt; +import androidx.core.app.NotificationCompat; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.FragmentActivity; +import androidx.security.crypto.EncryptedSharedPreferences; +import androidx.security.crypto.MasterKey; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.Locale; +import java.util.concurrent.Executor; + +/** + * WailsBridge manages the connection between the Java/Android side and the Go + * native library. It handles: + * - Loading and initializing the native Go library + * - Serving asset requests from Go + * - Passing messages between JavaScript and Go + * - Native facilities the Go side calls via JNI (dialogs, clipboard, + * screen/device info, toasts, vibration, main-thread dispatch) + */ +public class WailsBridge { + private static final String TAG = "WailsBridge"; + private static final boolean DEBUG = BuildConfig.DEBUG; + private static final int LOCATION_PERMISSION_REQUEST = 1002; + + static { + // Load the native Go library + System.loadLibrary("wails"); + } + + /** The live bridge, for in-process components that are not given one. */ + private static volatile WailsBridge instance; + + private final Activity activity; + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private WebView webView; + private volatile boolean initialized = false; + + // Phase D state: sensor listeners, speech engine and keyboard watcher are + // retained so they can be registered and torn down on demand. + private SensorEventListener accelListener; + private SensorEventListener proximityListener; + private long lastMotionEmit = 0; + private TextToSpeech tts; + private View.OnApplyWindowInsetsListener keyboardListener; + // Battery: remember the user's intent so sensors paused while the app is + // backgrounded can be restored on foreground; the torch is switched off. + private boolean motionWanted = false; + private boolean proximityWanted = false; + private boolean torchOn = false; + private boolean pendingLocationRequest = false; + + // Native methods - implemented in Go + private static native void nativeInit(WailsBridge bridge); + private static native void nativeShutdown(); + private static native void nativeOnStart(); + private static native void nativeOnResume(); + private static native void nativeOnPause(); + private static native void nativeOnStop(); + private static native void nativeOnLowMemory(); + private static native void nativeOnPageFinished(String url); + private static native byte[] nativeServeAsset(String path, String method, String headers); + private static native String nativeHandleMessage(String message); + private static native String nativeHandleRuntimeCall(String payload); + private static native String nativeGetAssetMimeType(String path); + private static native void nativeDialogCallback(int callbackID, int buttonIndex); + private static native void nativeFilePickerResult(int callbackID, String path); + private static native void nativeFilePickerDone(int callbackID); + private static native void nativeMainThreadCallback(int callbackID); + private static native void nativeEmitSystemEvent(String name, String json); + private static native void nativeEmitEvent(String name, String json); + + public WailsBridge(Activity activity) { + this.activity = activity; + instance = this; + } + + /** + * Initialize the native Go library + */ + public void initialize() { + if (initialized) { + return; + } + try { + nativeInit(this); + initialized = true; + Log.i(TAG, "Wails bridge initialized"); + } catch (Exception e) { + Log.e(TAG, "Failed to initialize Wails bridge", e); + } + } + + /** + * Shutdown the native Go library + */ + public void shutdown() { + if (!initialized) { + return; + } + try { + nativeShutdown(); + initialized = false; + } catch (Exception e) { + Log.e(TAG, "Error during shutdown", e); + } + } + + /** + * The WebView used for JavaScript execution. Must be set before the page + * loads. + */ + public void setWebView(WebView webView) { + this.webView = webView; + } + + // Lifecycle forwarding + + public void onStart() { + if (initialized) nativeOnStart(); + resumeFeaturesForForeground(); + } + + public void onResume() { + if (initialized) nativeOnResume(); + } + + public void onPause() { + if (initialized) nativeOnPause(); + } + + public void onStop() { + if (initialized) nativeOnStop(); + pauseFeaturesForBackground(); + } + + public void onLowMemory() { + if (initialized) nativeOnLowMemory(); + } + + /** + * Notify Go that the page finished loading. + */ + public void onPageFinished(String url) { + if (initialized) nativeOnPageFinished(url); + } + + /** + * Emit a "system:*" event (battery, network, lock, theme, lifecycle) to JS. + * Called from the system-event receivers registered by MainActivity. + */ + public void emitSystemEvent(String name, String json) { + if (initialized) nativeEmitSystemEvent(name, json); + } + + /** + * Emit an arbitrary custom event with a JSON payload to JS. Used by the + * mobile-feature bridges to deliver asynchronous results. + */ + public void emitEvent(String name, String json) { + if (initialized) nativeEmitEvent(name, json); + } + + /** + * Emit an event from a component that holds no bridge reference — + * {@link WailsForegroundService}, which Android constructs itself. It is a + * static hop rather than a binder because the service runs in this same + * process; before the bridge exists (or after it is gone) the event is + * dropped, which is the same thing {@link #emitEvent} does when the native + * library has not been initialized. + */ + public static void emitFromService(String name, String json) { + WailsBridge b = instance; + if (b != null) b.emitEvent(name, json); + } + + /** + * Serve an asset from the Go asset server + */ + public byte[] serveAsset(String path, String method, String headers) { + if (!initialized) { + Log.w(TAG, "Bridge not initialized, cannot serve asset: " + path); + return null; + } + if (DEBUG) Log.d(TAG, "Serving asset: " + path); + try { + return nativeServeAsset(path, method, headers); + } catch (Exception e) { + Log.e(TAG, "Error serving asset: " + path, e); + return null; + } + } + + /** + * Get the MIME type for an asset + */ + public String getAssetMimeType(String path) { + if (!initialized) { + return "application/octet-stream"; + } + try { + String mimeType = nativeGetAssetMimeType(path); + return mimeType != null ? mimeType : "application/octet-stream"; + } catch (Exception e) { + return "application/octet-stream"; + } + } + + /** + * Handle a message from JavaScript + */ + public String handleMessage(String message) { + if (!initialized) { + Log.w(TAG, "Bridge not initialized, cannot handle message"); + return "{\"error\":\"Bridge not initialized\"}"; + } + if (DEBUG) Log.d(TAG, "Message from JS: " + message); + try { + return nativeHandleMessage(message); + } catch (Exception e) { + Log.e(TAG, "Error handling message", e); + return "{\"error\":\"" + e.getMessage() + "\"}"; + } + } + + /** + * Handle a runtime call from JavaScript (the Android transport). + * The payload and response are JSON strings. + */ + public String handleRuntimeCall(String payload) { + if (!initialized) { + return "{\"ok\":false,\"error\":\"Bridge not initialized\"}"; + } + if (DEBUG) Log.d(TAG, "Runtime call: " + payload); + try { + return nativeHandleRuntimeCall(payload); + } catch (Exception e) { + Log.e(TAG, "Error in runtime call", e); + return "{\"ok\":false,\"error\":\"" + e.getMessage() + "\"}"; + } + } + + /** + * Execute JavaScript in the WebView. Called from Go via JNI (any thread). + */ + public void executeJavaScript(final String js) { + final WebView view = webView; + if (view == null) { + Log.w(TAG, "executeJavaScript: no WebView attached"); + return; + } + mainHandler.post(() -> view.evaluateJavascript(js, null)); + } + + // Facilities called from Go via JNI + + /** + * Screen metrics as JSON: hardware pixels, density and system bar insets. + */ + public String getScreenInfoJson() { + try { + JSONObject result = new JSONObject(); + + int insetTop = 0, insetBottom = 0, insetLeft = 0, insetRight = 0; + int widthPx, heightPx; + float density; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + android.view.WindowMetrics wm = activity.getWindowManager().getCurrentWindowMetrics(); + Rect bounds = wm.getBounds(); + widthPx = bounds.width(); + heightPx = bounds.height(); + density = activity.getResources().getDisplayMetrics().density; + android.graphics.Insets insets = wm.getWindowInsets() + .getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()); + insetTop = insets.top; + insetBottom = insets.bottom; + insetLeft = insets.left; + insetRight = insets.right; + } else { + DisplayMetrics metrics = new DisplayMetrics(); + activity.getWindowManager().getDefaultDisplay().getRealMetrics(metrics); + widthPx = metrics.widthPixels; + heightPx = metrics.heightPixels; + density = metrics.density; + } + + result.put("widthPx", widthPx); + result.put("heightPx", heightPx); + result.put("density", density); + result.put("insetTop", insetTop); + result.put("insetBottom", insetBottom); + result.put("insetLeft", insetLeft); + result.put("insetRight", insetRight); + return result.toString(); + } catch (Exception e) { + Log.e(TAG, "getScreenInfoJson failed", e); + return ""; + } + } + + /** + * Device information as JSON. + */ + public String getDeviceInfoJson() { + try { + JSONObject result = new JSONObject(); + result.put("manufacturer", Build.MANUFACTURER); + result.put("brand", Build.BRAND); + result.put("model", Build.MODEL); + result.put("device", Build.DEVICE); + result.put("version", Build.VERSION.RELEASE); + result.put("sdkInt", Build.VERSION.SDK_INT); + return result.toString(); + } catch (Exception e) { + return ""; + } + } + + public boolean isDarkMode() { + int mode = activity.getResources().getConfiguration().uiMode + & Configuration.UI_MODE_NIGHT_MASK; + return mode == Configuration.UI_MODE_NIGHT_YES; + } + + public boolean isMainThread() { + return Looper.myLooper() == Looper.getMainLooper(); + } + + /** + * Post a Go callback onto the Android main thread. + */ + public void runOnMainThread(final int callbackID) { + // Guard against the callback firing after shutdown() tore down the Go side + mainHandler.post(() -> { + if (initialized) nativeMainThreadCallback(callbackID); + }); + } + + // Clipboard (note: reads on Android 10+ require input focus) + + public void setClipboardText(String text) { + try { + ClipboardManager cm = (ClipboardManager) activity.getSystemService(Context.CLIPBOARD_SERVICE); + cm.setPrimaryClip(ClipData.newPlainText("wails", text)); + } catch (Exception e) { + Log.e(TAG, "setClipboardText failed", e); + } + } + + public String getClipboardText() { + try { + ClipboardManager cm = (ClipboardManager) activity.getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clip = cm.getPrimaryClip(); + if (clip != null && clip.getItemCount() > 0) { + CharSequence text = clip.getItemAt(0).coerceToText(activity); + return text != null ? text.toString() : ""; + } + } catch (Exception e) { + Log.e(TAG, "getClipboardText failed", e); + } + return ""; + } + + public void showToast(final String message) { + mainHandler.post(() -> Toast.makeText(activity, message, Toast.LENGTH_SHORT).show()); + } + + public void vibrate(int durationMs) { + try { + Vibrator vibrator = (Vibrator) activity.getSystemService(Context.VIBRATOR_SERVICE); + if (vibrator == null || !vibrator.hasVibrator()) { + return; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createOneShot(durationMs, VibrationEffect.DEFAULT_AMPLITUDE)); + } else { + vibrator.vibrate(durationMs); + } + } catch (Exception e) { + Log.e(TAG, "vibrate failed", e); + } + } + + // MARK: - Mobile features (Phase A) + + /** + * Present the Android share chooser. json: {"text": "...", "url": "..."}. + */ + public void share(final String json) { + mainHandler.post(() -> { + try { + JSONObject opts = new JSONObject(json); + String text = opts.optString("text", ""); + String url = opts.optString("url", ""); + StringBuilder body = new StringBuilder(); + if (!text.isEmpty()) body.append(text); + if (!url.isEmpty()) { + if (body.length() > 0) body.append("\n"); + body.append(url); + } + if (body.length() == 0) return; + Intent send = new Intent(Intent.ACTION_SEND); + send.setType("text/plain"); + send.putExtra(Intent.EXTRA_TEXT, body.toString()); + Intent chooser = Intent.createChooser(send, null); + chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + activity.startActivity(chooser); + } catch (Exception e) { + Log.e(TAG, "share failed", e); + } + }); + } + + /** + * Open a URL in the system browser. + */ + public void openURL(final String url) { + mainHandler.post(() -> { + try { + Intent view = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + view.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + activity.startActivity(view); + } catch (Exception e) { + Log.e(TAG, "openURL failed", e); + } + }); + } + + /** + * Keep the screen on (1) or release the hold (0) via FLAG_KEEP_SCREEN_ON. + */ + public void setKeepAwake(final int enabled) { + mainHandler.post(() -> { + if (enabled != 0) { + activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } else { + activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + }); + } + + /** + * Toggle the camera flash (torch). Emits "common:torch" with the resulting + * state and availability. + */ + public void setTorch(final int enabled) { + mainHandler.post(() -> { + try { + CameraManager cm = (CameraManager) activity.getSystemService(Context.CAMERA_SERVICE); + String flashId = null; + for (String id : cm.getCameraIdList()) { + Boolean hasFlash = cm.getCameraCharacteristics(id) + .get(CameraCharacteristics.FLASH_INFO_AVAILABLE); + if (Boolean.TRUE.equals(hasFlash)) { + flashId = id; + break; + } + } + if (flashId == null) { + emitEvent("common:torch", "{\"on\":false,\"available\":false}"); + return; + } + cm.setTorchMode(flashId, enabled != 0); + torchOn = enabled != 0; + emitEvent("common:torch", + enabled != 0 ? "{\"on\":true,\"available\":true}" + : "{\"on\":false,\"available\":true}"); + } catch (Exception e) { + Log.e(TAG, "setTorch failed", e); + emitEvent("common:torch", "{\"on\":false,\"available\":false}"); + } + }); + } + + // MARK: - Mobile features (Phase B) + + /** + * System-bar insets as JSON {"top","bottom","left","right"} in px. + */ + public String getSafeAreaJson() { + try { + int top = 0, bottom = 0, left = 0, right = 0; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + android.graphics.Insets insets = activity.getWindowManager() + .getCurrentWindowMetrics().getWindowInsets() + .getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()); + top = insets.top; bottom = insets.bottom; left = insets.left; right = insets.right; + } + return new JSONObject() + .put("top", top).put("bottom", bottom) + .put("left", left).put("right", right).toString(); + } catch (Exception e) { + return "{\"top\":0,\"bottom\":0,\"left\":0,\"right\":0}"; + } + } + + /** + * Set window brightness, 0-100. A negative value restores the system default. + */ + public void setBrightness(final int pct) { + mainHandler.post(() -> { + try { + WindowManager.LayoutParams lp = activity.getWindow().getAttributes(); + lp.screenBrightness = pct < 0 ? WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + : Math.max(0.01f, Math.min(1f, pct / 100f)); + activity.getWindow().setAttributes(lp); + } catch (Exception e) { + Log.e(TAG, "setBrightness failed", e); + } + }); + } + + /** + * Current brightness as {"value": 0.0-1.0}. Falls back to the system + * brightness setting when the window has not overridden it. + */ + public String getBrightnessJson() { + try { + float v = activity.getWindow().getAttributes().screenBrightness; + if (v < 0) { + int sys = Settings.System.getInt(activity.getContentResolver(), + Settings.System.SCREEN_BRIGHTNESS, 128); + v = sys / 255f; + } + return new JSONObject().put("value", v).toString(); + } catch (Exception e) { + return "{\"value\":-1}"; + } + } + + /** + * App info as JSON {"name","version","build","bundleId"}. + */ + public String getAppInfoJson() { + try { + PackageInfo pi = activity.getPackageManager() + .getPackageInfo(activity.getPackageName(), 0); + long code = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P + ? pi.getLongVersionCode() + : pi.versionCode; + CharSequence label = activity.getApplicationInfo() + .loadLabel(activity.getPackageManager()); + return new JSONObject() + .put("name", label != null ? label.toString() : "") + .put("version", pi.versionName != null ? pi.versionName : "") + .put("build", String.valueOf(code)) + .put("bundleId", activity.getPackageName()) + .toString(); + } catch (Exception e) { + return "{}"; + } + } + + /** + * Lock orientation to "portrait", "landscape" or "auto". + */ + public void setOrientation(final String mode) { + mainHandler.post(() -> { + int o = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED; + if ("portrait".equals(mode)) o = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT; + else if ("landscape".equals(mode)) o = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE; + activity.setRequestedOrientation(o); + }); + } + + /** + * Current orientation as {"orientation":"portrait"|"landscape"}. + */ + public String getOrientationJson() { + int o = activity.getResources().getConfiguration().orientation; + String s = o == Configuration.ORIENTATION_LANDSCAPE ? "landscape" : "portrait"; + try { + return new JSONObject().put("orientation", s).toString(); + } catch (Exception e) { + return "{\"orientation\":\"" + s + "\"}"; + } + } + + /** + * Set status-bar appearance. json: {"style":"light|dark|default","hidden":bool}. + * "light" = light (white) icons; "dark" = dark icons. + */ + public void setStatusBar(final String json) { + mainHandler.post(() -> { + try { + JSONObject opts = new JSONObject(json); + String style = opts.optString("style", "default"); + boolean hidden = opts.optBoolean("hidden", false); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + WindowInsetsController c = activity.getWindow().getInsetsController(); + if (c != null) { + if ("dark".equals(style)) { + c.setSystemBarsAppearance( + WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS, + WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS); + } else if ("light".equals(style)) { + c.setSystemBarsAppearance(0, + WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS); + } + if (hidden) c.hide(WindowInsets.Type.statusBars()); + else c.show(WindowInsets.Type.statusBars()); + } + } else { + int vis = activity.getWindow().getDecorView().getSystemUiVisibility(); + if ("dark".equals(style)) vis |= View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR; + else if ("light".equals(style)) vis &= ~View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR; + if (hidden) vis |= View.SYSTEM_UI_FLAG_FULLSCREEN; + else vis &= ~View.SYSTEM_UI_FLAG_FULLSCREEN; + activity.getWindow().getDecorView().setSystemUiVisibility(vis); + } + } catch (Exception e) { + Log.e(TAG, "setStatusBar failed", e); + } + }); + } + + // MARK: - Mobile features (Phase C) + + private void emitBiometric(boolean ok, String error) { + try { + JSONObject o = new JSONObject().put("ok", ok); + if (error != null) o.put("error", error); + emitEvent("common:biometric", o.toString()); + } catch (Exception ignored) { + } + } + + /** + * Show the BiometricPrompt (biometric or device credential). The outcome is + * emitted as "common:biometric" {ok, error}. + */ + public void authenticate(final String reason) { + mainHandler.post(() -> { + try { + int allowed = BiometricManager.Authenticators.BIOMETRIC_WEAK + | BiometricManager.Authenticators.DEVICE_CREDENTIAL; + BiometricManager bm = BiometricManager.from(activity); + if (bm.canAuthenticate(allowed) != BiometricManager.BIOMETRIC_SUCCESS) { + emitBiometric(false, "no biometrics or device credential enrolled"); + return; + } + Executor exec = ContextCompat.getMainExecutor(activity); + BiometricPrompt prompt = new BiometricPrompt((FragmentActivity) activity, exec, + new BiometricPrompt.AuthenticationCallback() { + @Override + public void onAuthenticationSucceeded(BiometricPrompt.AuthenticationResult result) { + emitBiometric(true, null); + } + @Override + public void onAuthenticationError(int code, CharSequence err) { + emitBiometric(false, err != null ? err.toString() : "error " + code); + } + // onAuthenticationFailed = a single non-match; prompt stays up, no terminal event. + }); + BiometricPrompt.PromptInfo info = new BiometricPrompt.PromptInfo.Builder() + .setTitle("Authenticate") + .setSubtitle(reason != null && !reason.isEmpty() ? reason : "Confirm it's you") + .setAllowedAuthenticators(allowed) + .build(); + prompt.authenticate(info); + } catch (Exception e) { + Log.e(TAG, "authenticate failed", e); + emitBiometric(false, "exception"); + } + }); + } + + /** + * Post a local notification. json: {"title","body"}. Requests the + * POST_NOTIFICATIONS runtime permission on Android 13+. + */ + public void postNotification(final String json) { + mainHandler.post(() -> { + try { + JSONObject opts = new JSONObject(json); + String title = opts.optString("title", "Notification"); + String body = opts.optString("body", ""); + String channelId = "wails_default"; + NotificationManager nm = + (NotificationManager) activity.getSystemService(Context.NOTIFICATION_SERVICE); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel ch = new NotificationChannel( + channelId, "General", NotificationManager.IMPORTANCE_DEFAULT); + nm.createNotificationChannel(ch); + } + if (Build.VERSION.SDK_INT >= 33 && activity.checkSelfPermission( + "android.permission.POST_NOTIFICATIONS") != PackageManager.PERMISSION_GRANTED) { + activity.requestPermissions( + new String[]{"android.permission.POST_NOTIFICATIONS"}, 1001); + } + Notification n = new NotificationCompat.Builder(activity, channelId) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle(title) + .setContentText(body) + .setAutoCancel(true) + .build(); + nm.notify((int) (System.currentTimeMillis() & 0x0fffffff), n); + emitEvent("common:notification", "{\"ok\":true}"); + } catch (Exception e) { + Log.e(TAG, "postNotification failed", e); + emitEvent("common:notification", "{\"ok\":false}"); + } + }); + } + + /** + * Backing store for secure storage. Uses EncryptedSharedPreferences (AES via + * the Android Keystore) on API 23+, falling back to plain prefs below that. + */ + private SharedPreferences securePrefs() { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + MasterKey key = new MasterKey.Builder(activity) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build(); + return EncryptedSharedPreferences.create(activity, "wails_secure", key, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM); + } + } catch (Exception e) { + Log.e(TAG, "securePrefs failed, using plain prefs", e); + } + return activity.getSharedPreferences("wails_secure_plain", Context.MODE_PRIVATE); + } + + /** Store a value in secure storage. json: {"key","value"}. */ + public void secureSet(final String json) { + try { + JSONObject o = new JSONObject(json); + securePrefs().edit().putString(o.optString("key"), o.optString("value")).apply(); + } catch (Exception e) { + Log.e(TAG, "secureSet failed", e); + } + } + + /** Read a value from secure storage (empty if absent). */ + public String secureGet(final String key) { + try { + return securePrefs().getString(key, ""); + } catch (Exception e) { + return ""; + } + } + + /** Remove a value from secure storage. */ + public void secureDelete(final String key) { + try { + securePrefs().edit().remove(key).apply(); + } catch (Exception e) { + Log.e(TAG, "secureDelete failed", e); + } + } + + // MARK: - Mobile features (Phase D: sensors & hardware) + + /** + * Play a haptic pattern via the Vibrator. type: impact-light|impact-medium| + * impact-heavy|success|warning|error|selection. + */ + public void haptic(final String type) { + try { + Vibrator vibrator = (Vibrator) activity.getSystemService(Context.VIBRATOR_SERVICE); + if (vibrator == null || !vibrator.hasVibrator()) return; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + int effect; + switch (type) { + case "impact-heavy": case "error": + effect = VibrationEffect.EFFECT_HEAVY_CLICK; break; + case "impact-light": case "selection": + effect = VibrationEffect.EFFECT_TICK; break; + case "success": case "warning": case "impact-medium": default: + effect = VibrationEffect.EFFECT_CLICK; break; + } + vibrator.vibrate(VibrationEffect.createPredefined(effect)); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + long ms = "impact-heavy".equals(type) || "error".equals(type) ? 40 + : "impact-light".equals(type) || "selection".equals(type) ? 10 : 20; + vibrator.vibrate(VibrationEffect.createOneShot(ms, VibrationEffect.DEFAULT_AMPLITUDE)); + } else { + vibrator.vibrate(20); + } + } catch (Exception e) { + Log.e(TAG, "haptic failed", e); + } + } + + private void emitLocationError(String msg) { + try { + emitEvent("common:location", new JSONObject().put("error", msg).toString()); + } catch (Exception ignored) { + } + } + + private boolean hasLocationPermission() { + return hasFineLocationPermission() + || activity.checkSelfPermission("android.permission.ACCESS_COARSE_LOCATION") + == PackageManager.PERMISSION_GRANTED; + } + + private boolean hasFineLocationPermission() { + return activity.checkSelfPermission("android.permission.ACCESS_FINE_LOCATION") + == PackageManager.PERMISSION_GRANTED; + } + + public void onRequestPermissionsResult(int requestCode, int[] grantResults) { + if (requestCode == LOCATION_PERMISSION_REQUEST) { + boolean shouldResume = pendingLocationRequest; + pendingLocationRequest = false; + if (!shouldResume) { + return; + } + if (hasLocationPermission()) { + getLocation(); + } else { + emitLocationError("location permission denied"); + } + } + } + + /** + * Request a one-shot location fix. Emits "common:location" + * {lat,lng,accuracy} or {error}. Requests ACCESS_FINE_LOCATION on first use. + */ + public void getLocation() { + mainHandler.post(() -> { + try { + if (!hasLocationPermission()) { + pendingLocationRequest = true; + activity.requestPermissions(new String[]{ + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.ACCESS_COARSE_LOCATION"}, LOCATION_PERMISSION_REQUEST); + return; + } + LocationManager lm = (LocationManager) activity.getSystemService(Context.LOCATION_SERVICE); + if (lm == null) { emitLocationError("location unavailable"); return; } + Location best = null; + boolean fineLocation = hasFineLocationPermission(); + String[] providers = fineLocation + ? new String[]{LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER, + LocationManager.PASSIVE_PROVIDER} + : new String[]{LocationManager.NETWORK_PROVIDER, LocationManager.PASSIVE_PROVIDER}; + for (String provider : providers) { + try { + Location l = lm.getLastKnownLocation(provider); + if (l != null && (best == null || l.getTime() > best.getTime())) best = l; + } catch (SecurityException | IllegalArgumentException ignored) { + } + } + if (best != null) { + emitLocation(best); + return; + } + // No cached fix: request a single update from whichever provider is enabled. + String provider = fineLocation && lm.isProviderEnabled(LocationManager.GPS_PROVIDER) + ? LocationManager.GPS_PROVIDER + : lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + ? LocationManager.NETWORK_PROVIDER : null; + if (provider == null) { emitLocationError("no location provider enabled"); return; } + lm.requestSingleUpdate(provider, new LocationListener() { + @Override public void onLocationChanged(Location location) { emitLocation(location); } + @Override public void onProviderEnabled(String p) {} + @Override public void onProviderDisabled(String p) {} + @Override public void onStatusChanged(String p, int s, android.os.Bundle e) {} + }, Looper.getMainLooper()); + } catch (Exception e) { + Log.e(TAG, "getLocation failed", e); + emitLocationError("exception"); + } + }); + } + + private void emitLocation(Location l) { + try { + emitEvent("common:location", new JSONObject() + .put("lat", l.getLatitude()) + .put("lng", l.getLongitude()) + .put("accuracy", l.getAccuracy()).toString()); + } catch (Exception ignored) { + } + } + + /** Start (1) / stop (0) accelerometer updates, streamed as "common:motion". */ + public void setMotion(final int enabled) { + motionWanted = enabled != 0; + mainHandler.post(() -> { + SensorManager sm = (SensorManager) activity.getSystemService(Context.SENSOR_SERVICE); + if (sm == null) return; + if (enabled != 0) { + if (accelListener != null) return; + Sensor accel = sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER); + if (accel == null) { emitEvent("common:motion", "{\"available\":false}"); return; } + accelListener = new SensorEventListener() { + @Override public void onSensorChanged(SensorEvent e) { + long now = System.currentTimeMillis(); + if (now - lastMotionEmit < 100) return; // throttle to ~10 Hz + lastMotionEmit = now; + try { + emitEvent("common:motion", new JSONObject() + .put("x", e.values[0]).put("y", e.values[1]) + .put("z", e.values[2]).toString()); + } catch (Exception ignored) { + } + } + @Override public void onAccuracyChanged(Sensor s, int a) {} + }; + sm.registerListener(accelListener, accel, SensorManager.SENSOR_DELAY_UI); + } else if (accelListener != null) { + sm.unregisterListener(accelListener); + accelListener = null; + } + }); + } + + /** Enable (1) / disable (0) the proximity sensor, reported as "common:proximity". */ + public void setProximity(final int enabled) { + proximityWanted = enabled != 0; + mainHandler.post(() -> { + SensorManager sm = (SensorManager) activity.getSystemService(Context.SENSOR_SERVICE); + if (sm == null) return; + if (enabled != 0) { + if (proximityListener != null) return; + Sensor prox = sm.getDefaultSensor(Sensor.TYPE_PROXIMITY); + if (prox == null) { emitEvent("common:proximity", "{\"available\":false}"); return; } + final float max = prox.getMaximumRange(); + proximityListener = new SensorEventListener() { + @Override public void onSensorChanged(SensorEvent e) { + boolean near = e.values[0] < max; + emitEvent("common:proximity", "{\"near\":" + (near ? "true" : "false") + "}"); + } + @Override public void onAccuracyChanged(Sensor s, int a) {} + }; + sm.registerListener(proximityListener, prox, SensorManager.SENSOR_DELAY_NORMAL); + } else if (proximityListener != null) { + sm.unregisterListener(proximityListener); + proximityListener = null; + } + }); + } + + /** + * Called when the activity leaves the foreground (onStop): stop the + * accelerometer and proximity sensor and switch the torch off so none of + * them drain the battery while the app isn't visible. The "wanted" flags are + * kept so foregrounding can restore the sensors. + */ + private void pauseFeaturesForBackground() { + mainHandler.post(() -> { + SensorManager sm = (SensorManager) activity.getSystemService(Context.SENSOR_SERVICE); + if (sm != null) { + if (accelListener != null) { sm.unregisterListener(accelListener); accelListener = null; } + if (proximityListener != null) { sm.unregisterListener(proximityListener); proximityListener = null; } + } + if (torchOn) { + setTorch(0); // turns the torch off, emits common:torch and clears torchOn + } + }); + } + + /** Re-enable on foreground (onStart) any sensors the user had switched on. */ + private void resumeFeaturesForForeground() { + if (motionWanted && accelListener == null) setMotion(1); + if (proximityWanted && proximityListener == null) setProximity(1); + } + + /** Speak text via TextToSpeech (lazily initialised). */ + public void speak(final String text) { + mainHandler.post(() -> { + if (text == null || text.isEmpty()) return; + if (tts == null) { + tts = new TextToSpeech(activity, status -> { + if (status == TextToSpeech.SUCCESS && tts != null) { + tts.setLanguage(Locale.US); + tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, "wails"); + } + }); + } else { + tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, "wails"); + } + }); + } + + /** Stop any in-progress speech. */ + public void stopSpeak() { + mainHandler.post(() -> { + if (tts != null) tts.stop(); + }); + } + + /** Disk space as {"free":bytes,"total":bytes}. */ + public String getStorageJson() { + try { + StatFs stat = new StatFs(activity.getFilesDir().getAbsolutePath()); + long free = stat.getAvailableBytes(); + long total = stat.getTotalBytes(); + return new JSONObject().put("free", free).put("total", total).toString(); + } catch (Exception e) { + return "{\"free\":0,\"total\":0}"; + } + } + + /** Absolute path to the app's private internal files directory + * (getFilesDir()), suitable for databases and other persistent files. */ + public String getStoragePath() { + java.io.File dir = activity.getFilesDir(); + return dir != null ? dir.getAbsolutePath() : ""; + } + + /** Battery/power state as {"level":0-1,"charging":bool,"lowPower":bool}. */ + public String getPowerJson() { + try { + IntentFilter filter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED); + android.content.Intent batt = activity.registerReceiver(null, filter); + float level = -1; + boolean charging = false; + if (batt != null) { + int raw = batt.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); + int scale = batt.getIntExtra(BatteryManager.EXTRA_SCALE, -1); + if (raw >= 0 && scale > 0) level = raw / (float) scale; + int status = batt.getIntExtra(BatteryManager.EXTRA_STATUS, -1); + charging = status == BatteryManager.BATTERY_STATUS_CHARGING + || status == BatteryManager.BATTERY_STATUS_FULL; + } + boolean lowPower = false; + PowerManager pm = (PowerManager) activity.getSystemService(Context.POWER_SERVICE); + if (pm != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + lowPower = pm.isPowerSaveMode(); + } + return new JSONObject().put("level", level) + .put("charging", charging).put("lowPower", lowPower).toString(); + } catch (Exception e) { + return "{\"level\":-1,\"charging\":false,\"lowPower\":false}"; + } + } + + /** Network status as {"connected":bool,"type":"wifi|cellular|ethernet|none"}. */ + public String getNetworkJson() { + try { + ConnectivityManager cm = + (ConnectivityManager) activity.getSystemService(Context.CONNECTIVITY_SERVICE); + boolean connected = false; + String type = "none"; + if (cm != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Network net = cm.getActiveNetwork(); + NetworkCapabilities caps = net != null ? cm.getNetworkCapabilities(net) : null; + 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 = "ethernet"; + } + } + return new JSONObject().put("connected", connected).put("type", type).toString(); + } catch (Exception e) { + return "{\"connected\":false,\"type\":\"none\"}"; + } + } + + /** + * Watch (1) / unwatch (0) the soft keyboard, emitting "common:keyboard" + * {visible,height} (height in px) via an inset listener on the content view. + */ + public void setKeyboardWatch(final int enabled) { + mainHandler.post(() -> { + final View content = activity.getWindow().getDecorView(); + if (enabled != 0) { + if (keyboardListener != null) return; + keyboardListener = (v, insets) -> { + int imeHeight = 0; + boolean visible; + if (Build.VERSION.SDK_INT >= 30) { + imeHeight = insets.getInsets(WindowInsets.Type.ime()).bottom; + visible = insets.isVisible(WindowInsets.Type.ime()); + } else { + imeHeight = insets.getSystemWindowInsetBottom(); + visible = imeHeight > 0; + } + emitKeyboard(visible, imeHeight); + return insets; + }; + content.setOnApplyWindowInsetsListener(keyboardListener); + content.requestApplyInsets(); + } else if (keyboardListener != null) { + content.setOnApplyWindowInsetsListener(null); + keyboardListener = null; + } + }); + } + + private void emitKeyboard(boolean visible, int height) { + try { + emitEvent("common:keyboard", + new JSONObject().put("visible", visible).put("height", height).toString()); + } catch (Exception ignored) { + } + } + + /** + * Toggle FLAG_SECURE (blocks screenshots & screen recording). Reports the new + * state as "common:screenCapture" {protected}. + */ + public void setScreenProtect(final int enabled) { + mainHandler.post(() -> { + if (enabled != 0) { + activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE); + } else { + activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE); + } + emitEvent("common:screenCapture", + "{\"protected\":" + (enabled != 0 ? "true" : "false") + "}"); + }); + } + + // MARK: - Mobile features (Phase E: camera & background) + + /** Capture a photo with the system camera. Result → "common:capture" event. */ + public void capturePhoto(final String json) { + mainHandler.post(() -> { + if (activity instanceof MainActivity) { + ((MainActivity) activity).launchCameraCapture(false); + } else { + emitEvent("common:capture", "{\"error\":\"camera unavailable\"}"); + } + }); + } + + /** Capture a video with the system camera. Result → "common:capture" event. */ + public void captureVideo(final String json) { + mainHandler.post(() -> { + if (activity instanceof MainActivity) { + ((MainActivity) activity).launchCameraCapture(true); + } else { + emitEvent("common:capture", "{\"error\":\"camera unavailable\"}"); + } + }); + } + + /** + * Start a foreground service that keeps the process alive for long-running + * background work (with an ongoing notification). json: {"title","text"}. + */ + public void startForegroundService(final String json) { + mainHandler.post(() -> { + try { + String title = "Wails", text = "Running in the background"; + try { + JSONObject o = new JSONObject(json); + title = o.optString("title", title); + text = o.optString("text", text); + } catch (Exception ignored) { + } + if (Build.VERSION.SDK_INT >= 33 && activity.checkSelfPermission( + "android.permission.POST_NOTIFICATIONS") != PackageManager.PERMISSION_GRANTED) { + activity.requestPermissions(new String[]{"android.permission.POST_NOTIFICATIONS"}, 1003); + } + Intent i = new Intent(activity, WailsForegroundService.class); + i.setAction(WailsForegroundService.ACTION_START); + i.putExtra("title", title); + i.putExtra("text", text); + // The whole document, for the media service: seven extras + // would be seven chances for the two sides to disagree about + // a key, and the service already has to parse JSON for the + // fields the scaffold's title/text pair cannot carry. + i.putExtra("payload", json); + if (WailsForegroundService.running) { + activity.startService(i); + } else { + ContextCompat.startForegroundService(activity, i); + } + emitEvent("android:foregroundService", "{\"running\":true}"); + } catch (Exception e) { + Log.e(TAG, "startForegroundService failed", e); + emitEvent("android:foregroundService", "{\"running\":false,\"error\":\"failed to start\"}"); + } + }); + } + + /** Stop the foreground service. */ + public void stopForegroundService() { + mainHandler.post(() -> { + try { + activity.stopService(new Intent(activity, WailsForegroundService.class)); + emitEvent("android:foregroundService", "{\"running\":false}"); + } catch (Exception e) { + Log.e(TAG, "stopForegroundService failed", e); + } + }); + } + + /** + * Show a message dialog. optionsJson: + * {"title": "...", "message": "...", + * "buttons": [{"label": "...", "isCancel": bool, "isDefault": bool}]} + * Calls nativeDialogCallback with the index of the pressed button in the + * original buttons array, or -1 when dismissed without a matching button. + */ + public void showMessageDialog(final int callbackID, final String optionsJson) { + mainHandler.post(() -> { + try { + JSONObject options = new JSONObject(optionsJson); + String title = options.optString("title", ""); + String message = options.optString("message", ""); + JSONArray buttons = options.optJSONArray("buttons"); + + AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder.setTitle(title); + builder.setMessage(message); + + int count = buttons != null ? buttons.length() : 0; + int cancelIndex = -1; + for (int i = 0; i < count; i++) { + if (buttons.getJSONObject(i).optBoolean("isCancel", false)) { + cancelIndex = i; + break; + } + } + final int dismissIndex = cancelIndex; + builder.setOnCancelListener(d -> dialogCallback(callbackID, dismissIndex)); + + if (count == 0) { + builder.setPositiveButton(android.R.string.ok, + (d, w) -> dialogCallback(callbackID, -1)); + } else if (count <= 3) { + // Map buttons to AlertDialog slots: the default (or last) + // button is positive, the cancel button negative, any + // remaining button neutral. + int positive = -1; + for (int i = 0; i < count; i++) { + if (buttons.getJSONObject(i).optBoolean("isDefault", false)) { + positive = i; + break; + } + } + if (positive == -1) { + positive = count - 1; + if (positive == cancelIndex && count > 1) { + positive = count - 2; + } + } + int negative = cancelIndex; + if (negative == -1) { + for (int i = count - 1; i >= 0; i--) { + if (i != positive) { + negative = i; + break; + } + } + } + int neutral = -1; + for (int i = 0; i < count; i++) { + if (i != positive && i != negative) { + neutral = i; + break; + } + } + + final int positiveIdx = positive, negativeIdx = negative, neutralIdx = neutral; + builder.setPositiveButton(buttons.getJSONObject(positive).optString("label", "OK"), + (d, w) -> dialogCallback(callbackID, positiveIdx)); + if (negative != -1) { + builder.setNegativeButton(buttons.getJSONObject(negative).optString("label", "Cancel"), + (d, w) -> dialogCallback(callbackID, negativeIdx)); + } + if (neutral != -1) { + builder.setNeutralButton(buttons.getJSONObject(neutral).optString("label", ""), + (d, w) -> dialogCallback(callbackID, neutralIdx)); + } + } else { + // More than three buttons: show as a list + String[] labels = new String[count]; + for (int i = 0; i < count; i++) { + labels[i] = buttons.getJSONObject(i).optString("label", ""); + } + builder.setItems(labels, (d, which) -> dialogCallback(callbackID, which)); + } + + builder.show(); + } catch (Exception e) { + Log.e(TAG, "showMessageDialog failed", e); + dialogCallback(callbackID, -1); + } + }); + } + + /** + * Show the system document picker. optionsJson: {"multiple": bool}. + * Results flow back through filePickerResult/filePickerDone. + */ + public void showFilePicker(final int callbackID, final String optionsJson) { + boolean multiple = false; + try { + multiple = new JSONObject(optionsJson).optBoolean("multiple", false); + } catch (Exception ignored) { + } + final boolean allowMultiple = multiple; + mainHandler.post(() -> { + if (activity instanceof MainActivity) { + ((MainActivity) activity).launchFilePicker(callbackID, allowMultiple); + } else { + Log.e(TAG, "showFilePicker: activity is not a MainActivity"); + nativeFilePickerDone(callbackID); + } + }); + } + + /** Forward a picked file path to Go (package-private, used by MainActivity). */ + void filePickerResult(int callbackID, String path) { + // The picker completes on a background thread that may outlive shutdown() + if (initialized) nativeFilePickerResult(callbackID, path); + } + + /** Signal the end of a file picking session (package-private). */ + void filePickerDone(int callbackID) { + if (initialized) nativeFilePickerDone(callbackID); + } + + /** Dialog button callback that no-ops once the Go side is gone. */ + private void dialogCallback(int callbackID, int buttonIndex) { + if (initialized) nativeDialogCallback(callbackID, buttonIndex); + } +} diff --git a/build/android/app/src/main/java/com/wails/app/WailsForegroundService.java b/build/android/app/src/main/java/com/wails/app/WailsForegroundService.java new file mode 100644 index 0000000..3954e63 --- /dev/null +++ b/build/android/app/src/main/java/com/wails/app/WailsForegroundService.java @@ -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. + * + *

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. + * + *

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 start 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; + } +} diff --git a/build/android/app/src/main/java/com/wails/app/WailsJSBridge.java b/build/android/app/src/main/java/com/wails/app/WailsJSBridge.java new file mode 100644 index 0000000..dac5f53 --- /dev/null +++ b/build/android/app/src/main/java/com/wails/app/WailsJSBridge.java @@ -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"); + } +} diff --git a/build/android/app/src/main/java/com/wails/app/WailsPathHandler.java b/build/android/app/src/main/java/com/wails/app/WailsPathHandler.java new file mode 100644 index 0000000..e6e5f88 --- /dev/null +++ b/build/android/app/src/main/java/com/wails/app/WailsPathHandler.java @@ -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 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 + ); + } +} diff --git a/build/android/app/src/main/res/layout/activity_main.xml b/build/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..f278384 --- /dev/null +++ b/build/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/build/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/build/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..9409abe Binary files /dev/null and b/build/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/build/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/build/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..9409abe Binary files /dev/null and b/build/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/build/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/build/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..5b6acc0 Binary files /dev/null and b/build/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/build/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/build/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..5b6acc0 Binary files /dev/null and b/build/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/build/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/build/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..1c2c664 Binary files /dev/null and b/build/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/build/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/build/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..1c2c664 Binary files /dev/null and b/build/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/build/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/build/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..be557d8 Binary files /dev/null and b/build/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/build/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/build/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..be557d8 Binary files /dev/null and b/build/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/build/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/build/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4507f32 Binary files /dev/null and b/build/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/build/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/build/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..4507f32 Binary files /dev/null and b/build/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/build/android/app/src/main/res/values/colors.xml b/build/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..96d4c17 --- /dev/null +++ b/build/android/app/src/main/res/values/colors.xml @@ -0,0 +1,13 @@ + + + #3574D4 + #2C5FB8 + + #000000 + #FFFFFFFF + #FF000000 + diff --git a/build/android/app/src/main/res/values/strings.xml b/build/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..887ff9d --- /dev/null +++ b/build/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + YellowJacket + diff --git a/build/android/app/src/main/res/values/themes.xml b/build/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..be8a282 --- /dev/null +++ b/build/android/app/src/main/res/values/themes.xml @@ -0,0 +1,14 @@ + + + + diff --git a/build/android/app/src/main/res/xml/file_paths.xml b/build/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..af670bb --- /dev/null +++ b/build/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/build/android/build.gradle b/build/android/build.gradle new file mode 100644 index 0000000..d7fbab3 --- /dev/null +++ b/build/android/build.gradle @@ -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 +} diff --git a/build/android/gradle.properties b/build/android/gradle.properties new file mode 100644 index 0000000..b9d4426 --- /dev/null +++ b/build/android/gradle.properties @@ -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 diff --git a/build/android/gradle/wrapper/gradle-wrapper.jar b/build/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f8e1ee3 Binary files /dev/null and b/build/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/build/android/gradle/wrapper/gradle-wrapper.properties b/build/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..23449a2 --- /dev/null +++ b/build/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/build/android/gradlew b/build/android/gradlew new file mode 100755 index 0000000..adff685 --- /dev/null +++ b/build/android/gradlew @@ -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" "$@" diff --git a/build/android/gradlew.bat b/build/android/gradlew.bat new file mode 100644 index 0000000..e509b2d --- /dev/null +++ b/build/android/gradlew.bat @@ -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 diff --git a/build/android/main_android.go b/build/android/main_android.go new file mode 100644 index 0000000..70a7164 --- /dev/null +++ b/build/android/main_android.go @@ -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) +} diff --git a/build/android/scripts/deps/install_deps.go b/build/android/scripts/deps/install_deps.go new file mode 100644 index 0000000..8b3165d --- /dev/null +++ b/build/android/scripts/deps/install_deps.go @@ -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. + // /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" +} diff --git a/build/android/settings.gradle b/build/android/settings.gradle new file mode 100644 index 0000000..a3f3ec3 --- /dev/null +++ b/build/android/settings.gradle @@ -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' diff --git a/cmd/indexexport/export_test.go b/cmd/indexexport/export_test.go new file mode 100644 index 0000000..d4c983b --- /dev/null +++ b/cmd/indexexport/export_test.go @@ -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 +} diff --git a/cmd/indexexport/main.go b/cmd/indexexport/main.go index 4831dfa..2393847 100644 --- a/cmd/indexexport/main.go +++ b/cmd/indexexport/main.go @@ -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 diff --git a/docs/android-release.md b/docs/android-release.md new file mode 100644 index 0000000..3284d1c --- /dev/null +++ b/docs/android-release.md @@ -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//yellowjacket-.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 '' \ + -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. diff --git a/e2e/specs/back-navigation.spec.ts b/e2e/specs/back-navigation.spec.ts new file mode 100644 index 0000000..7d16e3b --- /dev/null +++ b/e2e/specs/back-navigation.spec.ts @@ -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 { + 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'); + }); +}); diff --git a/e2e/specs/explore-shelves.spec.ts b/e2e/specs/explore-shelves.spec.ts index 40aff8d..9b5bae5 100644 --- a/e2e/specs/explore-shelves.spec.ts +++ b/e2e/specs/explore-shelves.spec.ts @@ -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 { - if ((await catalogRows(app)) > 0) return; +async function stageCatalog(app: Page): Promise { // 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 { 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 { + 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; } /** diff --git a/e2e/specs/layout-overflow.spec.ts b/e2e/specs/layout-overflow.spec.ts index 5bfbaff..f5cd20a 100644 --- a/e2e/specs/layout-overflow.spec.ts +++ b/e2e/specs/layout-overflow.spec.ts @@ -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 }); }); diff --git a/e2e/specs/long-press.spec.ts b/e2e/specs/long-press.spec.ts new file mode 100644 index 0000000..33a8d0b --- /dev/null +++ b/e2e/specs/long-press.spec.ts @@ -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 { + 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(); + }); +}); diff --git a/e2e/specs/phone-shell.spec.ts b/e2e/specs/phone-shell.spec.ts new file mode 100644 index 0000000..e6aadf9 --- /dev/null +++ b/e2e/specs/phone-shell.spec.ts @@ -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(); + }); +}); diff --git a/frontend/bindings/yellowjacket/backend/frontendutil/frontendutil.ts b/frontend/bindings/yellowjacket/backend/frontendutil/frontendutil.ts index 3915b07..5041c81 100644 --- a/frontend/bindings/yellowjacket/backend/frontendutil/frontendutil.ts +++ b/frontend/bindings/yellowjacket/backend/frontendutil/frontendutil.ts @@ -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 { + return $Call.ByID(497852148); +} + /** * DirectoryPicker opens a directory selection dialog. * @@ -20,6 +49,22 @@ export function DirectoryPicker(): $CancellablePromise { 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 { + 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 { 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. diff --git a/frontend/bindings/yellowjacket/backend/frontendutil/index.ts b/frontend/bindings/yellowjacket/backend/frontendutil/index.ts index 5c57627..dd55c18 100644 --- a/frontend/bindings/yellowjacket/backend/frontendutil/index.ts +++ b/frontend/bindings/yellowjacket/backend/frontendutil/index.ts @@ -5,3 +5,9 @@ import * as FrontendUtil from "./frontendutil.js"; export { FrontendUtil }; + +export type { + DirEntry, + DirListing, + StorageAccess +} from "./models.js"; diff --git a/frontend/bindings/yellowjacket/backend/frontendutil/models.ts b/frontend/bindings/yellowjacket/backend/frontendutil/models.ts new file mode 100644 index 0000000..07c0668 --- /dev/null +++ b/frontend/bindings/yellowjacket/backend/frontendutil/models.ts @@ -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; +} diff --git a/frontend/index.css b/frontend/index.css index 63adc99..5d5fe0a 100644 --- a/frontend/index.css +++ b/frontend/index.css @@ -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; + } +} diff --git a/frontend/index.html b/frontend/index.html index 86cb586..7af95aa 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -41,6 +41,13 @@ + + diff --git a/frontend/index.ts b/frontend/index.ts index 095ca2b..0b05564 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -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 Promise> = { 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(); 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 { @@ -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' diff --git a/frontend/src/assets/icons/fa/solid/bars.svg b/frontend/src/assets/icons/fa/solid/bars.svg new file mode 100644 index 0000000..8cb740d --- /dev/null +++ b/frontend/src/assets/icons/fa/solid/bars.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/audio-player/audio-player.ts b/frontend/src/components/audio-player/audio-player.ts index bb5192b..f05bbf5 100644 --- a/frontend/src/components/audio-player/audio-player.ts +++ b/frontend/src/components/audio-player/audio-player.ts @@ -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() { diff --git a/frontend/src/components/audio-player/seekbar/seek-bar.ts b/frontend/src/components/audio-player/seekbar/seek-bar.ts index 127a679..95f38aa 100644 --- a/frontend/src/components/audio-player/seekbar/seek-bar.ts +++ b/frontend/src/components/audio-player/seekbar/seek-bar.ts @@ -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; diff --git a/frontend/src/components/bottom-nav/bottom-nav.ts b/frontend/src/components/bottom-nav/bottom-nav.ts new file mode 100644 index 0000000..b7fc825 --- /dev/null +++ b/frontend/src/components/bottom-nav/bottom-nav.ts @@ -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 `` 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` +

+ + + ${this.drawerOpen + ? html`` + : nothing} + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'bottom-nav': BottomNav; + } +} diff --git a/frontend/src/components/config-page/config-page.ts b/frontend/src/components/config-page/config-page.ts index 986ac62..cf7d9b9 100644 --- a/frontend/src/components/config-page/config-page.ts +++ b/frontend/src/components/config-page/config-page.ts @@ -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; diff --git a/frontend/src/components/config-page/download-clients.ts b/frontend/src/components/config-page/download-clients.ts index cdeb150..cf36c44 100644 --- a/frontend/src/components/config-page/download-clients.ts +++ b/frontend/src/components/config-page/download-clients.ts @@ -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 }; diff --git a/frontend/src/components/first-run-wizard/first-run-wizard.ts b/frontend/src/components/first-run-wizard/first-run-wizard.ts index 96316ff..447ac6b 100644 --- a/frontend/src/components/first-run-wizard/first-run-wizard.ts +++ b/frontend/src/components/first-run-wizard/first-run-wizard.ts @@ -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) { diff --git a/frontend/src/components/folder-picker/folder-picker.ts b/frontend/src/components/folder-picker/folder-picker.ts new file mode 100644 index 0000000..5a5728c --- /dev/null +++ b/frontend/src/components/folder-picker/folder-picker.ts @@ -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 { + 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((resolve) => { + this.settle = resolve; + }); + } + + private async load(path: string): Promise { + 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` + this.close(null)} + > +

+ ${this.path || '\u2026'} +

+ + ${this.errorMessage + ? html`` + : nothing} + +
    + ${this.parent + ? html`
  • + +
  • ` + : nothing} + ${this.entries.map( + (entry) => html` +
  • + +
  • + `, + )} + ${!this.loading && this.entries.length === 0 + ? html`
  • No folders here.
  • ` + : nothing} +
+ + +

+ ${this.loading + ? 'Loading folders' + : `${this.entries.length} folders in ${this.path}`} +

+ +
+ + +
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'folder-picker': FolderPicker; + } +} diff --git a/frontend/src/components/jobs/job-indicator.ts b/frontend/src/components/jobs/job-indicator.ts index 115868b..0791b04 100644 --- a/frontend/src/components/jobs/job-indicator.ts +++ b/frontend/src/components/jobs/job-indicator.ts @@ -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; diff --git a/frontend/src/components/now-playing-view/now-playing-view.ts b/frontend/src/components/now-playing-view/now-playing-view.ts new file mode 100644 index 0000000..2ec9699 --- /dev/null +++ b/frontend/src/components/now-playing-view/now-playing-view.ts @@ -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 + * ``, `` and `` 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()} +

+ Nothing is playing. +

+ `; + } + + 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()} + +
+ ${art + ? html`` + : html``} +
+ +
+
+

+ ${track.title || track.fileName} +

+

+ ${artistLink(track.artist, track.artistMbid)} +

+ ${track.album + ? html`

+ ${albumLink( + track.album, + track.releaseGroupMbid, + undefined, + track.artist, + )} +

` + : nothing} +
+ + +
+ +
+ + + +
+ `; + } + + private renderHeader() { + return html` +
+ + Now playing + +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'now-playing-view': NowPlayingView; + } +} diff --git a/frontend/src/components/now-playing/now-playing.ts b/frontend/src/components/now-playing/now-playing.ts index 6d12938..48d0524 100644 --- a/frontend/src/components/now-playing/now-playing.ts +++ b/frontend/src/components/now-playing/now-playing.ts @@ -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 {
${announcement}
+
{ + this.dispatchEvent(new CustomEvent('navigate', { + detail: { view: 'now-playing' }, + bubbles: true, + composed: true, + })); + }; + // =================================================================== // SCROLL LOGIC // =================================================================== diff --git a/frontend/src/components/search-bar/search-bar.ts b/frontend/src/components/search-bar/search-bar.ts index deaea42..c0b0ab9 100644 --- a/frontend/src/components/search-bar/search-bar.ts +++ b/frontend/src/components/search-bar/search-bar.ts @@ -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); } diff --git a/frontend/src/components/sidebar/app-sidebar.ts b/frontend/src/components/sidebar/app-sidebar.ts index dcd55d7..5a185e7 100644 --- a/frontend/src/components/sidebar/app-sidebar.ts +++ b/frontend/src/components/sidebar/app-sidebar.ts @@ -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; diff --git a/frontend/src/icons/names.txt b/frontend/src/icons/names.txt index b07fb7b..f68ea52 100644 --- a/frontend/src/icons/names.txt +++ b/frontend/src/icons/names.txt @@ -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 diff --git a/frontend/src/utils/long-press.ts b/frontend/src/utils/long-press.ts new file mode 100644 index 0000000..1f52ff1 --- /dev/null +++ b/frontend/src/utils/long-press.ts @@ -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(); + +/** + * 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 | 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; +} diff --git a/frontend/src/utils/pick-directory.ts b/frontend/src/utils/pick-directory.ts new file mode 100644 index 0000000..18033b9 --- /dev/null +++ b/frontend/src/utils/pick-directory.ts @@ -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 | 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 { + 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 { + 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 { + 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; +} diff --git a/frontend/test/components/bottom-nav.test.ts b/frontend/test/components/bottom-nav.test.ts new file mode 100644 index 0000000..1949c7f --- /dev/null +++ b/frontend/test/components/bottom-nav.test.ts @@ -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(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((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