Merge remote-tracking branch 'origin/main' into wails-v3
@@ -0,0 +1,427 @@
|
|||||||
|
name: Build & publish the Android APK
|
||||||
|
|
||||||
|
# The fifth workflow, and the second that publishes. It builds a signed
|
||||||
|
# arm64-v8a APK on every version tag and puts it in
|
||||||
|
# Gitea's *generic* package registry, which — unlike the repository — is
|
||||||
|
# readable without credentials. That is what lets an Obtainium client
|
||||||
|
# poll a plain URL with no token and no public mirror of the source.
|
||||||
|
#
|
||||||
|
# **Why its own file rather than a job in ci.yml.** `ci.yml` runs on
|
||||||
|
# every branch push and is the workflow that gates; this one runs on
|
||||||
|
# tags only, takes tens of minutes on a cold cache, and the runner has
|
||||||
|
# capacity 1. Hanging it off the gate would put every push behind an
|
||||||
|
# SDK download.
|
||||||
|
#
|
||||||
|
# **Why it is keyed on the tag.** The ljos pipeline this is modelled on
|
||||||
|
# computes a version in CI and cuts the release itself, then gates the
|
||||||
|
# Android job on `needs.release.outputs.version != ''` with an
|
||||||
|
# `always()` whose absence silently kills the manual path. This repo
|
||||||
|
# has no release automation — tags are pushed by hand and
|
||||||
|
# homebrew-formula.yml already keys on `v*` — so the tag *is* the
|
||||||
|
# version and none of that machinery, or its failure modes, is needed.
|
||||||
|
#
|
||||||
|
# It deliberately does **not** carry `continue-on-error`. In ljos the
|
||||||
|
# Android job shared a pipeline with a server deploy that must never go
|
||||||
|
# red over a phone build; here it is standalone and can neither delay
|
||||||
|
# nor redden anything, so a release step that fails silently would be
|
||||||
|
# strictly worse than one that fails visibly.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to build (default: the latest v* tag)"
|
||||||
|
required: false
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: android-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
apk:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
container:
|
||||||
|
image: ubuntu:24.04
|
||||||
|
# /cache/tool holds the Go toolchain ci.yml already downloads.
|
||||||
|
# The other three are this workflow's own and are ~4 GB between
|
||||||
|
# them, which is most of its wall clock on a cold run:
|
||||||
|
# android-sdk the SDK, the NDK and the platform (~2 GB)
|
||||||
|
# gradle GRADLE_USER_HOME — the wrapper distribution and
|
||||||
|
# the AGP dependency graph (~700 MB)
|
||||||
|
# pnpm-store shared with ci.yml
|
||||||
|
# Every path must be inside the runner's `valid_volumes` allowlist:
|
||||||
|
# a directory outside it makes the job **fail to start**, rather
|
||||||
|
# than silently skipping the mount.
|
||||||
|
volumes:
|
||||||
|
- /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool
|
||||||
|
- /home/logan/docker/gitea/data/runner/cache/android-sdk:/cache/android-sdk
|
||||||
|
- /home/logan/docker/gitea/data/runner/cache/gradle:/cache/gradle
|
||||||
|
- /home/logan/docker/gitea/data/runner/cache/pnpm-store:/cache/pnpm-store
|
||||||
|
env:
|
||||||
|
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||||
|
SERVER_URL: ${{ github.server_url }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
OWNER: ${{ github.repository_owner }}
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
DEBIAN_FRONTEND: noninteractive
|
||||||
|
GO_VERSION: '1.25.0'
|
||||||
|
npm_config_store_dir: /cache/pnpm-store
|
||||||
|
# The Go half wants the NDK; the Gradle half wants a platform.
|
||||||
|
ANDROID_HOME: /cache/android-sdk
|
||||||
|
ANDROID_SDK_ROOT: /cache/android-sdk
|
||||||
|
GRADLE_USER_HOME: /cache/gradle
|
||||||
|
# Pinned, not "whatever sdkmanager installs": newer NDKs have
|
||||||
|
# broken the Wails Android build before, and r26d is what plan
|
||||||
|
# 015 phase 0 was verified against.
|
||||||
|
NDK_VERSION: 26.3.11579264
|
||||||
|
# The registry package name. Obtainium watches
|
||||||
|
# <server>/api/packages/<owner>/generic/yellowjacket-android/latest/yellowjacket.apk
|
||||||
|
PACKAGE_NAME: yellowjacket-android
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# libgtk-4-dev and libwebkitgtk-6.0-dev are here even though
|
||||||
|
# nothing in this job builds a desktop app: `wails3` is the task
|
||||||
|
# runner the whole Android build goes through, and the CLI links
|
||||||
|
# the GTK/WebKit bindings, so `go tool wails3` cannot compile
|
||||||
|
# without them. libasound2-dev is oto's `pkg-config -- alsa`
|
||||||
|
# probe, for the same reason (the *Android* build uses oboe, not
|
||||||
|
# ALSA — this is the host toolchain only).
|
||||||
|
- name: System packages
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq --no-install-recommends \
|
||||||
|
ca-certificates curl git jq unzip zip \
|
||||||
|
build-essential pkg-config \
|
||||||
|
libwebkitgtk-6.0-dev libgtk-4-dev libasound2-dev \
|
||||||
|
openjdk-21-jdk-headless
|
||||||
|
|
||||||
|
# By hand rather than actions/checkout: that is a JS action and
|
||||||
|
# needs node inside the container before any step has installed
|
||||||
|
# it. Same approach as the other four workflows.
|
||||||
|
- name: Clone repo at this commit
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
git clone --quiet \
|
||||||
|
"https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" /src
|
||||||
|
git -C /src checkout --quiet --detach "$SHA"
|
||||||
|
git config --global --add safe.directory /src
|
||||||
|
git -C /src log --oneline -1
|
||||||
|
|
||||||
|
# A tag push carries the version in its own name. A manual run has
|
||||||
|
# no tag, so it takes the input or falls back to the latest v* tag,
|
||||||
|
# which is what a hand-triggered rebuild wants anyway.
|
||||||
|
- name: Resolve the version
|
||||||
|
id: version
|
||||||
|
working-directory: /src
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
v="${{ inputs.version }}"
|
||||||
|
if [ -z "$v" ]; then
|
||||||
|
case "$REF_NAME" in
|
||||||
|
v*) v="$REF_NAME" ;;
|
||||||
|
*) v=$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null || echo "v0.0.0") ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
v="${v#v}"
|
||||||
|
|
||||||
|
# Android orders releases by an integer and refuses anything
|
||||||
|
# not greater than what is installed. 1.3.1 -> 10301, which
|
||||||
|
# increases as long as minor and patch stay below 100.
|
||||||
|
IFS=. read -r maj min pat <<EOF
|
||||||
|
$v
|
||||||
|
EOF
|
||||||
|
code=$(( ${maj:-0} * 10000 + ${min:-0} * 100 + ${pat:-0} ))
|
||||||
|
if [ "$code" -le 0 ]; then
|
||||||
|
echo "refusing to build version '$v' (versionCode $code)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "code=$code" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "building $v (versionCode $code)"
|
||||||
|
|
||||||
|
- name: Go toolchain
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
if [ ! -x /cache/tool/go/bin/go ] || ! /cache/tool/go/bin/go version | grep -q "$GO_VERSION"; then
|
||||||
|
mkdir -p /cache/tool && rm -rf /cache/tool/go
|
||||||
|
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" | tar -C /cache/tool -xz
|
||||||
|
fi
|
||||||
|
echo "/cache/tool/go/bin" >> "$GITHUB_PATH"
|
||||||
|
/cache/tool/go/bin/go version
|
||||||
|
|
||||||
|
- name: Node toolchain
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||||
|
apt-get install -y -qq --no-install-recommends nodejs
|
||||||
|
corepack enable
|
||||||
|
node --version
|
||||||
|
|
||||||
|
# Idempotent by directory check. sdkmanager is itself idempotent
|
||||||
|
# but still spends minutes verifying, so the guards are what make
|
||||||
|
# this cheap on every run after the first.
|
||||||
|
- name: Android SDK and NDK (cached)
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||||
|
|
||||||
|
if [ ! -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
|
||||||
|
echo "command line tools: installing"
|
||||||
|
cd /tmp
|
||||||
|
curl -fsSL -o tools.zip \
|
||||||
|
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||||
|
unzip -q tools.zip
|
||||||
|
rm -rf "$ANDROID_HOME/cmdline-tools/latest"
|
||||||
|
mv cmdline-tools "$ANDROID_HOME/cmdline-tools/latest"
|
||||||
|
else
|
||||||
|
echo "command line tools: cached"
|
||||||
|
fi
|
||||||
|
|
||||||
|
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
|
||||||
|
yes | sdkmanager --licenses >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
install_if_missing() {
|
||||||
|
if [ -d "$ANDROID_HOME/$2" ]; then
|
||||||
|
echo "$1: cached"
|
||||||
|
else
|
||||||
|
echo "$1: installing"
|
||||||
|
yes | sdkmanager --install "$1" >/dev/null
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
# android-35 matches compileSdk/targetSdk in
|
||||||
|
# build/android/app/build.gradle. No system image and no
|
||||||
|
# emulator: this job builds, it does not run.
|
||||||
|
install_if_missing "platform-tools" "platform-tools"
|
||||||
|
install_if_missing "platforms;android-35" "platforms/android-35"
|
||||||
|
install_if_missing "build-tools;34.0.0" "build-tools/34.0.0"
|
||||||
|
install_if_missing "ndk;${NDK_VERSION}" "ndk/${NDK_VERSION}"
|
||||||
|
|
||||||
|
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/${NDK_VERSION}" >> "$GITHUB_ENV"
|
||||||
|
du -sh "$ANDROID_HOME" || true
|
||||||
|
|
||||||
|
# **Signing is not optional past the first install.** Android
|
||||||
|
# refuses to update an app whose signing key changed and the only
|
||||||
|
# remedy is an uninstall, which takes the user's library with it.
|
||||||
|
# build.gradle falls back to the *debug* keystore when these are
|
||||||
|
# absent, and that key differs between every machine and every
|
||||||
|
# runner — so publishing an unsigned build is a decision to
|
||||||
|
# reinstall by hand for ever. Fail instead.
|
||||||
|
# **Signing is not optional past the first install.** Android
|
||||||
|
# refuses to update an app whose signing key changed and the only
|
||||||
|
# remedy is an uninstall, which takes the user's library with it.
|
||||||
|
# build.gradle falls back to the *debug* keystore when these are
|
||||||
|
# absent, and that key differs between every machine and every
|
||||||
|
# runner — so publishing an unsigned build is a decision to
|
||||||
|
# reinstall by hand for ever. Fail instead.
|
||||||
|
#
|
||||||
|
# Decode, check and build are one step on purpose. Splitting them
|
||||||
|
# would mean either handing the password to a later step through
|
||||||
|
# `$GITHUB_ENV` — where the `env:` dump is only masked for values
|
||||||
|
# that are *verbatim* a secret, so a trimmed one could print in
|
||||||
|
# clear — or repeating the trimming logic in both.
|
||||||
|
- name: Build the signed APK
|
||||||
|
working-directory: /src
|
||||||
|
env:
|
||||||
|
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
YJ_VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
YJ_VERSION_CODE: ${{ steps.version.outputs.code }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [ -z "${KEYSTORE_B64:-}" ]; then
|
||||||
|
echo "ANDROID_KEYSTORE_B64 is not set."
|
||||||
|
echo
|
||||||
|
echo "Building without it signs with the debug key, and every future"
|
||||||
|
echo "update then fails with a signature mismatch. See"
|
||||||
|
echo "docs/android-release.md for the keytool command and the secrets."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "${KEYSTORE_PASSWORD:-}" ]; then
|
||||||
|
echo "ANDROID_KEYSTORE_PASSWORD is not set — see docs/android-release.md" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The path is decided here rather than composed in an `env:`
|
||||||
|
# block: `${{ env.HOME }}` evaluates to an empty string in
|
||||||
|
# Gitea's expression context, which turns "$HOME/x.jks" into
|
||||||
|
# "/x.jks" — reported by Gradle as a missing file, a minute in.
|
||||||
|
keystore="${RUNNER_TEMP:-/tmp}/yellowjacket-release.jks"
|
||||||
|
printf '%s' "$KEYSTORE_B64" | base64 -d > "$keystore"
|
||||||
|
chmod 600 "$keystore"
|
||||||
|
|
||||||
|
# **A secret pasted into a web form very often carries a
|
||||||
|
# trailing newline**, and a password is compared byte for byte.
|
||||||
|
# Trim CR and LF from all three, and say so when it mattered —
|
||||||
|
# "the keystore did not open" with a correct password is an
|
||||||
|
# unpleasant thing to debug blind.
|
||||||
|
pass=$(printf '%s' "$KEYSTORE_PASSWORD" | tr -d '\r\n')
|
||||||
|
if [ "${#pass}" -ne "${#KEYSTORE_PASSWORD}" ]; then
|
||||||
|
echo "note: stripped newline(s) from ANDROID_KEYSTORE_PASSWORD"
|
||||||
|
fi
|
||||||
|
alias_want=$(printf '%s' "${KEY_ALIAS:-yellowjacket}" | tr -d '\r\n')
|
||||||
|
keypass=$(printf '%s' "${KEY_PASSWORD:-$pass}" | tr -d '\r\n')
|
||||||
|
|
||||||
|
# Describe the artifact before trying to open it. A truncated
|
||||||
|
# or mis-pasted base64 yields a file that is the wrong size or
|
||||||
|
# has no keystore header at all, and that is a different
|
||||||
|
# problem from a wrong password.
|
||||||
|
size=$(stat -c %s "$keystore")
|
||||||
|
magic=$(od -An -N4 -tx1 "$keystore" | tr -s ' ' | sed 's/^ //')
|
||||||
|
echo "keystore: $size bytes, first four bytes: $magic"
|
||||||
|
|
||||||
|
# The fingerprint of the decoded file, so "is the secret the
|
||||||
|
# keystore I have locally?" is answerable without guessing.
|
||||||
|
# A hash of a *public* certificate store gives nothing away,
|
||||||
|
# and the alternative is comparing byte counts by eye.
|
||||||
|
#
|
||||||
|
# sha256sum ~/path/to/yellowjacket-release.jks
|
||||||
|
#
|
||||||
|
# A password that is right for one keystore and wrong for
|
||||||
|
# another is indistinguishable from a wrong password, and this
|
||||||
|
# is the line that distinguishes them.
|
||||||
|
echo " sha256: $(sha256sum "$keystore" | cut -d' ' -f1)"
|
||||||
|
case "$magic" in
|
||||||
|
"30 82"*) echo " header: PKCS12 (keytool's default since JDK 9)" ;;
|
||||||
|
"fe ed fe ed") echo " header: legacy JKS" ;;
|
||||||
|
*) echo " WARNING: not a keystore header. Is the secret the base64 of the .jks?" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Open it here rather than letting Gradle discover the problem
|
||||||
|
# at :app:validateSigningRelease, a minute of build time in and
|
||||||
|
# reported as a missing file rather than a bad password.
|
||||||
|
if ! keytool -list -keystore "$keystore" -storepass "$pass" >/tmp/ks.txt 2>/tmp/ks.err; then
|
||||||
|
echo "the keystore did not open with ANDROID_KEYSTORE_PASSWORD." >&2
|
||||||
|
echo " password length after trimming: ${#pass}" >&2
|
||||||
|
sed 's/^/ keytool: /' /tmp/ks.err | head -5 >&2
|
||||||
|
echo >&2
|
||||||
|
|
||||||
|
# A password pasted *with its shell quotes* is the one
|
||||||
|
# remaining cause that looks identical to a wrong password:
|
||||||
|
# the secret is two characters longer than the password and
|
||||||
|
# nothing in the error says so. Naming it is safe --
|
||||||
|
# stripping the quotes and carrying on would not be, since a
|
||||||
|
# password may legitimately contain them.
|
||||||
|
unquoted=$(printf '%s' "$pass" | sed "s/^['\"]//;s/['\"]$//")
|
||||||
|
if [ "$unquoted" != "$pass" ] &&
|
||||||
|
keytool -list -keystore "$keystore" -storepass "$unquoted" >/dev/null 2>&1; then
|
||||||
|
echo " ** it opens with the surrounding quotes removed. **" >&2
|
||||||
|
echo " Re-paste ANDROID_KEYSTORE_PASSWORD without them." >&2
|
||||||
|
echo >&2
|
||||||
|
fi
|
||||||
|
echo "Check it locally with the same two values:" >&2
|
||||||
|
echo " printf %s \"\$SECRET_B64\" | base64 -d > /tmp/k.jks" >&2
|
||||||
|
echo " keytool -list -keystore /tmp/k.jks -storepass '<password>'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "keystore opens with the supplied password"
|
||||||
|
|
||||||
|
# And check the alias now, for the same reason. It defaults to
|
||||||
|
# `yellowjacket`, so a keystore created with any other alias
|
||||||
|
# would otherwise fail deep inside Gradle.
|
||||||
|
if ! keytool -list -keystore "$keystore" -storepass "$pass" -alias "$alias_want" >/dev/null 2>&1; then
|
||||||
|
echo "alias '$alias_want' is not in this keystore. It holds:" >&2
|
||||||
|
sed -n 's/^\([^,]*\),.*Entry.*$/ \1/p' /tmp/ks.txt >&2
|
||||||
|
echo "Set ANDROID_KEY_ALIAS to one of those." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "alias '$alias_want': present"
|
||||||
|
|
||||||
|
ANDROID_KEYSTORE_FILE="$keystore"
|
||||||
|
ANDROID_KEYSTORE_PASSWORD="$pass"
|
||||||
|
ANDROID_KEY_ALIAS="$alias_want"
|
||||||
|
ANDROID_KEY_PASSWORD="$keypass"
|
||||||
|
export ANDROID_KEYSTORE_FILE ANDROID_KEYSTORE_PASSWORD
|
||||||
|
export ANDROID_KEY_ALIAS ANDROID_KEY_PASSWORD
|
||||||
|
|
||||||
|
# ANDROID_SDK is passed explicitly: the Makefile defaults it to
|
||||||
|
# ~/Android/Sdk, which is the developer-machine layout and not
|
||||||
|
# this container's.
|
||||||
|
make android ANDROID_SDK="$ANDROID_HOME" ANDROID_NDK="$ANDROID_NDK_HOME"
|
||||||
|
|
||||||
|
- name: Verify the APK
|
||||||
|
id: apk
|
||||||
|
working-directory: /src
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
apk=bin/yellowjacket.apk
|
||||||
|
[ -s "$apk" ] || { echo "no APK was produced" >&2; ls -la bin || true; exit 1; }
|
||||||
|
bt="$ANDROID_HOME/build-tools/34.0.0"
|
||||||
|
|
||||||
|
ls -la "$apk"
|
||||||
|
"$bt/aapt2" dump badging "$apk" | sed -n '1p;/application-label:/p;/native-code/p'
|
||||||
|
|
||||||
|
# arm64 and *only* arm64. x86_64 Android cannot run this app
|
||||||
|
# (modernc's raw lstat against Android's seccomp filter, which
|
||||||
|
# is every x86_64 device and not merely the emulator), so an
|
||||||
|
# x86_64 slice would be ~31 MB that runs nowhere -- and its
|
||||||
|
# reappearance would mean someone had put the ABI back in
|
||||||
|
# app/build.gradle without knowing that.
|
||||||
|
"$bt/aapt2" dump badging "$apk" | grep -q "native-code: 'arm64-v8a'$" || {
|
||||||
|
echo "the APK's ABI set is not exactly arm64-v8a" >&2; exit 1; }
|
||||||
|
|
||||||
|
# The identity the pipeline exists to keep stable.
|
||||||
|
"$bt/aapt2" dump badging "$apk" | grep -q "versionCode='${{ steps.version.outputs.code }}'" || {
|
||||||
|
echo "versionCode is not ${{ steps.version.outputs.code }}" >&2; exit 1; }
|
||||||
|
|
||||||
|
echo
|
||||||
|
"$bt/apksigner" verify --print-certs "$apk" |
|
||||||
|
grep -E 'Signer #1 certificate (DN|SHA-256 digest)'
|
||||||
|
|
||||||
|
# A build signed with the debug key installs once and can never
|
||||||
|
# be updated. It must never reach the registry.
|
||||||
|
if "$bt/apksigner" verify --print-certs "$apk" | grep -q 'CN=Android Debug'; then
|
||||||
|
echo "REFUSING TO PUBLISH: signed with the debug keystore" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
echo "Record that SHA-256. If it ever changes, updates will fail."
|
||||||
|
|
||||||
|
# Two copies: a versioned one for history and a fixed `latest` URL
|
||||||
|
# for Obtainium to watch. Gitea refuses to overwrite an existing
|
||||||
|
# file, so `latest` is deleted first. Credentials are the same
|
||||||
|
# OWNER/PACKAGE_TOKEN pair arch-package.yml publishes with.
|
||||||
|
- name: Publish to the Gitea package registry
|
||||||
|
working-directory: /src
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
base="${SERVER_URL}/api/packages/${OWNER}/generic/${PACKAGE_NAME}"
|
||||||
|
apk=bin/yellowjacket.apk
|
||||||
|
|
||||||
|
put() {
|
||||||
|
code=$(curl -s -o /tmp/put.out -w '%{http_code}' \
|
||||||
|
--user "${OWNER}:${PACKAGE_TOKEN}" \
|
||||||
|
--upload-file "$apk" "$1")
|
||||||
|
echo " -> $1 : $code"
|
||||||
|
# 409 is "already there", which is the correct outcome for a
|
||||||
|
# re-run of the same tag and not a failure.
|
||||||
|
if [ "$code" != "201" ] && [ "$code" != "409" ]; then
|
||||||
|
cat /tmp/put.out >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "publishing the versioned copy"
|
||||||
|
put "$base/$VERSION/yellowjacket-$VERSION.apk"
|
||||||
|
|
||||||
|
echo "clearing the previous latest"
|
||||||
|
curl -s -o /dev/null -w ' -> delete latest: %{http_code}\n' \
|
||||||
|
--user "${OWNER}:${PACKAGE_TOKEN}" \
|
||||||
|
-X DELETE "$base/latest/yellowjacket.apk" || true
|
||||||
|
|
||||||
|
echo "publishing latest"
|
||||||
|
put "$base/latest/yellowjacket.apk"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Obtainium URL:"
|
||||||
|
echo " $base/latest/yellowjacket.apk"
|
||||||
@@ -63,10 +63,24 @@ bin/
|
|||||||
# packaging tasks that depend on it. A derived file with one source.
|
# packaging tasks that depend on it. A derived file with one source.
|
||||||
build/linux/yellowjacket.desktop
|
build/linux/yellowjacket.desktop
|
||||||
|
|
||||||
# `wails3 task common:update:build-assets` regenerates the mobile trees
|
# iOS is not carried. `wails3 update build-assets` regenerates the tree
|
||||||
# whether or not anything asks for them. This is a desktop player and
|
# whether or not anything asks for it, so it is ignored rather than
|
||||||
# cannot target iOS/Android, so their includes: entries are dropped from
|
# deleted-and-rediscovered on every asset refresh, and its includes:
|
||||||
# Taskfile.yml and the trees themselves are not carried — ignored rather
|
# entry is dropped from Taskfile.yml.
|
||||||
# than deleted-and-rediscovered on every asset refresh.
|
#
|
||||||
|
# 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/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
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ linters:
|
|||||||
- usetesting
|
- usetesting
|
||||||
- whitespace
|
- whitespace
|
||||||
- wsl_v5
|
- 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:
|
formatters:
|
||||||
enable:
|
enable:
|
||||||
- gci
|
- gci
|
||||||
|
|||||||
@@ -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
|
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
|
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.
|
reads exactly like a regression in whatever you are holding. `make dev-headless SEED=default` first.
|
||||||
|
- **The catalog is stubbed out locally now, like CI.**
|
||||||
|
`dev-headless.sh` defaults `YJ_CORE_INDEX_URL` to a dead address
|
||||||
|
because it was the only launcher that did not — `seed-sandbox.sh` and
|
||||||
|
`ci.yml` always have. Without it the app downloads the real ~1M-row
|
||||||
|
Explore catalog into the run's `YJ_HOME`, and specs that stage their
|
||||||
|
own catalog rows then search a million real ones and fail *locally
|
||||||
|
only*, which reads as a regression and is an environment. Pass
|
||||||
|
`YJ_CORE_INDEX_URL=<real url>` when you want the real catalog to
|
||||||
|
explore by hand.
|
||||||
- **…and the suite spends state it cannot always give back.**
|
- **…and the suite spends state it cannot always give back.**
|
||||||
`view-lifecycle.spec.ts` **skips an autotag album** on every run, out
|
`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
|
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 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 |
|
| 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) | |
|
| 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**
|
Two targets are once-per-clone prerequisites that are **not**
|
||||||
dependencies of the targets needing them, so on a fresh checkout each
|
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.
|
and what breaks in it.
|
||||||
- [schema-change.md](references/schema-change.md) — the two-file
|
- [schema-change.md](references/schema-change.md) — the two-file
|
||||||
schema/migration checklist.
|
schema/migration checklist.
|
||||||
|
- [android-tier.md](references/android-tier.md) — the emulator tier,
|
||||||
|
and the three reasons a failure there looks like a success. **Read
|
||||||
|
its first section before running anything on Android**: Go's stdout
|
||||||
|
does not reach logcat, `os.Exit` leaves no panic and no tombstone,
|
||||||
|
and ActivityManager restarts a dying app fast enough that `pidof`
|
||||||
|
always answers.
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
# The Android tier
|
||||||
|
|
||||||
|
A sixth tier, and the only one where **the app failing looks exactly
|
||||||
|
like the app working**. Read the first section before you run anything;
|
||||||
|
it is the difference between a diagnosis and an afternoon.
|
||||||
|
|
||||||
|
This tier answers "does the phone build run", nothing else. It is not a
|
||||||
|
spec tier, it does not run in CI, and the app is not a usable Android
|
||||||
|
player yet (plan 015 says why, at length).
|
||||||
|
|
||||||
|
## Three facts that make failure invisible
|
||||||
|
|
||||||
|
**Go's stdout does not reach logcat.** An Android app's fd 1 and 2 go to
|
||||||
|
`/dev/null`. Every `slog` line the app writes is discarded — including
|
||||||
|
the one naming the error it is about to exit on. `setprop
|
||||||
|
log.redirect-stdio true` does not help: it redirects the *Java*
|
||||||
|
runtime's `System.out`, and the Go code is a c-shared native library.
|
||||||
|
|
||||||
|
**`os.Exit` is a silent death.** `main()` ends several failure paths in
|
||||||
|
`os.Exit(1)`. From Android's side that is a process that vanished:
|
||||||
|
`ActivityManager: Process com.wails.app has died`, `Zygote: exited due
|
||||||
|
to signal 9`, and **no** panic, **no** `AndroidRuntime` stack, **no**
|
||||||
|
tombstone under `/data/tombstones` and nothing in `logcat -b crash` or
|
||||||
|
dropbox. All three of the places you would look are empty, and the one
|
||||||
|
signal that is present — SIGKILL — reads as "the system killed it",
|
||||||
|
which is the wrong hypothesis.
|
||||||
|
|
||||||
|
**ActivityManager restarts it, so a dead app looks alive.** A
|
||||||
|
crash-looping app is respawned several times a second, so `pidof` always
|
||||||
|
answers and `am start` always reports `Status: ok`. "Did it start" is
|
||||||
|
the wrong question. `make android-smoke` asks the right one — is it the
|
||||||
|
*same pid* a few seconds later.
|
||||||
|
|
||||||
|
The tell, once you know it: `I/WailsBridge: Wails bridge initialized`
|
||||||
|
followed immediately by a new pid doing the same thing. That means the
|
||||||
|
native library loaded, the JNI bridge came up, Go's `main()` ran, and
|
||||||
|
`main()` left. Work backwards through its `os.Exit(1)` paths.
|
||||||
|
|
||||||
|
## What to run
|
||||||
|
|
||||||
|
One-time, ~3.5 GB:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make android-setup # SDK pieces + the yj-test AVD, idempotent
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make android # arm64-v8a APK -> bin/yellowjacket.apk (~16 MB)
|
||||||
|
make android-emulator # boot headless in the background, wait for boot
|
||||||
|
make android-install # adb install -r
|
||||||
|
make android-smoke # launch, then assert the same pid survives 10s
|
||||||
|
make android-logs # filtered logcat, follow
|
||||||
|
make android-emulator-stop # console kill, then the saved PID
|
||||||
|
```
|
||||||
|
|
||||||
|
`make android-smoke SECONDS=30` for a longer window. On failure it
|
||||||
|
prints the last 40 app-relevant logcat lines and how to read them.
|
||||||
|
|
||||||
|
Never `pkill -f emulator` — the pattern matches the invoking shell's own
|
||||||
|
command line and kills it, silently dropping the rest of your compound
|
||||||
|
command. The emulator is addressed by its saved pid in
|
||||||
|
`.dev/emulator.pid`, same discipline as `make dev-stop`.
|
||||||
|
|
||||||
|
**adb is addressed by AVD name, not by whatever is plugged in.** The
|
||||||
|
script resolves `ANDROID_SERIAL` from `ro.boot.qemu.avd_name` before
|
||||||
|
any device command, because a second emulator (another project's, or
|
||||||
|
this one's own corpse left `offline` by a previous run) makes a bare
|
||||||
|
`adb` fail with "more than one device" — which `cmd_install` reported
|
||||||
|
as *"no device — run 'make android-emulator' first"* immediately after
|
||||||
|
that had succeeded. Serials are assigned in boot order and change
|
||||||
|
between runs, so the AVD name is the identity. Set `ANDROID_SERIAL`
|
||||||
|
yourself and it is honoured; one device that is not ours (a phone) is
|
||||||
|
taken as the target.
|
||||||
|
|
||||||
|
## Things that cost a cycle
|
||||||
|
|
||||||
|
- **`ANDROID_HOME` must carry a platform, and Arch's does not.**
|
||||||
|
`/opt/android-sdk` (the `android-sdk` package) has an NDK and
|
||||||
|
build-tools but `platforms/` is *empty*, so Gradle fails with a
|
||||||
|
compileSdk error that reads like a version mismatch. The Makefile
|
||||||
|
defaults `ANDROID_SDK` to `~/Android/Sdk` (user-owned, writable,
|
||||||
|
where sdkmanager puts things) and `ANDROID_NDK` to `/opt/android-ndk`
|
||||||
|
separately, because the Go half wants the NDK and the Gradle half
|
||||||
|
wants the platform and they are in different places.
|
||||||
|
- **The NDK is pinned to r26d** (`26.3.11579264`, Arch's
|
||||||
|
`android-ndk-26`). Newer NDKs have broken the Wails Android build
|
||||||
|
before. CI pins the same one.
|
||||||
|
- **Without KVM the emulator still works and is unusably slow** — a 30 s
|
||||||
|
boot becomes tens of minutes, which reads as a hung target rather than
|
||||||
|
a slow one. `make android-setup` checks and warns.
|
||||||
|
- **`-no-snapshot` is deliberate.** A snapshot-resumed emulator carries
|
||||||
|
the previous run's app state, and a smoke result that depends on what
|
||||||
|
the last run left behind is not a result.
|
||||||
|
- **The logcat filter is not optional.** The emulator emits thousands of
|
||||||
|
lines a second, nearly all WindowManager transitions; an unfiltered
|
||||||
|
`adb logcat` buries the six lines that matter. `make android-logs`
|
||||||
|
filters to `WailsBridge`, the app's own tag, `GoLog`, `AndroidRuntime`,
|
||||||
|
`DEBUG` and `libc:F`.
|
||||||
|
- **`run-as` does not work on a release-signed APK** (`package not
|
||||||
|
debuggable`), so you cannot read the app's data directory or its
|
||||||
|
environment that way. Ask the device instead, or build a debug variant.
|
||||||
|
- **The `google_apis` system image, not `default`.** This app is a
|
||||||
|
WebView app; `google_apis` ships the Chrome-based WebView that
|
||||||
|
actually renders it.
|
||||||
|
|
||||||
|
## The current state of the build
|
||||||
|
|
||||||
|
**The app starts. The x86_64 emulator cannot run it, and that is not a
|
||||||
|
bug in the app.**
|
||||||
|
|
||||||
|
`modernc.org/libc` — which `modernc.org/sqlite`, and therefore the whole
|
||||||
|
database layer, sits on — issues a **raw `lstat` syscall on
|
||||||
|
linux/amd64** (`libc_linux_amd64.go`'s `Xlstat64` calls
|
||||||
|
`unix.Syscall(unix.SYS_LSTAT, …)`). Android's seccomp policy forbids
|
||||||
|
syscall 6 on x86_64, because bionic never issues it, so the process
|
||||||
|
takes `SIGSYS` the first time anything touches the database:
|
||||||
|
|
||||||
|
```
|
||||||
|
F/libc: Fatal signal 31 (SIGSYS), code 1 (SYS_SECCOMP), syscall 6
|
||||||
|
F/DEBUG: Cause: seccomp prevented call to disallowed x86_64 system call 6
|
||||||
|
```
|
||||||
|
|
||||||
|
**arm64 is unaffected, and structurally so.** There is no `lstat`
|
||||||
|
syscall on arm64 at all, so `ccgo_linux_arm64.go`'s `Xlstat` is
|
||||||
|
`Xfstatat(…, AT_SYMLINK_NOFOLLOW)` → `SYS_newfstatat` (79), which
|
||||||
|
Android permits. `grep -c SYS_LSTAT ccgo_linux_arm64.go` is 0. Go's own
|
||||||
|
`syscall` package already uses `fstatat` on both architectures, which
|
||||||
|
is why this is *only* the modernc path.
|
||||||
|
|
||||||
|
So: **verify on arm64, and on this machine that means a real device.**
|
||||||
|
`make android-smoke` on an x86_64 AVD reports a `SIGSYS` tombstone that
|
||||||
|
says nothing about your change.
|
||||||
|
|
||||||
|
**Do not reach for an arm64 system image — it will not run here, and
|
||||||
|
finding that out costs a 3.8 GB download.** Emulator 37 refuses
|
||||||
|
outright:
|
||||||
|
|
||||||
|
```
|
||||||
|
FATAL | Avd's CPU Architecture 'arm64' is not supported by the QEMU2
|
||||||
|
emulator on x86_64 host. System image must match the host
|
||||||
|
architecture.
|
||||||
|
```
|
||||||
|
|
||||||
|
Google dropped cross-architecture emulation; there is no flag. The
|
||||||
|
options are an arm64 host, a physical device, or `adb connect` to one.
|
||||||
|
|
||||||
|
**The x86_64 ABI is therefore gone from the build** (`abiFilters` in
|
||||||
|
`build/android/app/build.gradle`, `android:package` rather than
|
||||||
|
`package:fat` in the Makefile, and a `native-code: 'arm64-v8a'$`
|
||||||
|
assertion in `android-apk.yml` that fails if it comes back). It could
|
||||||
|
not run on any Android until modernc fixes this — x86 Chromebooks
|
||||||
|
included — and dropping it took the artifact from 27 MB to 15.9 MB.
|
||||||
|
The tombstone was at least honest while it lasted: unlike the
|
||||||
|
`os.Exit` that came before it, it left a real crash record with a
|
||||||
|
backtrace.
|
||||||
|
|
||||||
|
### The emulator still installs it, and it still does not run
|
||||||
|
|
||||||
|
The obvious guess about dropping x86_64 — that `make android-install`
|
||||||
|
would now refuse with `INSTALL_FAILED_NO_MATCHING_ABIS` — is **wrong,
|
||||||
|
and was measured wrong before it was written down.** Google's
|
||||||
|
`google_apis` x86_64 images carry arm64 translation:
|
||||||
|
|
||||||
|
```
|
||||||
|
ro.product.cpu.abilist = x86_64,arm64-v8a
|
||||||
|
```
|
||||||
|
|
||||||
|
So the arm64-only APK installs, the loader maps `lib/arm64/libwails.so`
|
||||||
|
and runs it (the tombstone says `Guest architecture: 'arm64'`). It then
|
||||||
|
dies **before any of our code**, with SIGILL rather than SIGSYS:
|
||||||
|
|
||||||
|
```
|
||||||
|
signal 4 (SIGILL), code -6 (SI_TKILL)
|
||||||
|
#00 pc 00000000015911d0 .../lib/arm64/libwails.so
|
||||||
|
```
|
||||||
|
|
||||||
|
Disassembling that offset names the reason exactly:
|
||||||
|
|
||||||
|
```
|
||||||
|
15911d0: d5380600 mrs x0, ID_AA64ISAR0_EL1
|
||||||
|
```
|
||||||
|
|
||||||
|
That is Go's `internal/cpu` reading the arm64 CPU-feature ID register
|
||||||
|
at runtime init, which the translator does not implement. So it is not
|
||||||
|
"our Go program is unlucky": **no Go binary starts under this
|
||||||
|
translation layer**, and no amount of work on this app changes it.
|
||||||
|
|
||||||
|
The three failures are worth holding side by side, because each looks
|
||||||
|
like the app's fault and none is:
|
||||||
|
|
||||||
|
| build | on x86_64 Android | signal |
|
||||||
|
|---|---|---|
|
||||||
|
| x86_64 | modernc's raw `lstat` vs seccomp | SIGSYS, syscall 6 |
|
||||||
|
| arm64, translated | Go reads `ID_AA64ISAR0_EL1` | SIGILL |
|
||||||
|
| arm64, real device | — | unverified, still |
|
||||||
|
|
||||||
|
**A physical arm64 device remains the only verification path.**
|
||||||
|
|
||||||
|
### What was fixed to get here
|
||||||
|
|
||||||
|
`backend/system`'s `buildUserDirPath` switched on `runtime.GOOS` with a
|
||||||
|
`default:` returning `errUnsupportedOS`, so Android failed at startup
|
||||||
|
and `main()` called `os.Exit(1)` six milliseconds after the bridge came
|
||||||
|
up. `main()` now calls `system.UseHomeOverride(application.Mobile.
|
||||||
|
StoragePath())` before anything asks for a path — a documented,
|
||||||
|
build-tag-free API that returns `""` on desktop, where the setter is a
|
||||||
|
no-op. `backend/system` gained no import of the Wails application
|
||||||
|
package, which matters for the same reason `backend/events` is split by
|
||||||
|
the `indexbuild` tag.
|
||||||
|
|
||||||
|
### What is still not done
|
||||||
|
|
||||||
|
The shell is still a desktop shell, and the x86_64 half of the APK is
|
||||||
|
still dead weight. Everything in plan 016's section A is now built:
|
||||||
|
storage access, an in-app folder picker (Android's directory dialog
|
||||||
|
returns an error, since the Storage Access Framework yields tree URIs
|
||||||
|
rather than paths), MPRIS excluded, and a MediaSession with a transport
|
||||||
|
notification and audio focus.
|
||||||
|
|
||||||
|
### Compiling the `android`-tagged Go by hand
|
||||||
|
|
||||||
|
`make lint` and `make test` never see it: their three tag sets are all
|
||||||
|
linux/amd64, so the only thing that compiles `backend/mediacontrols/
|
||||||
|
android.go` is `make android` — a full APK build for a Go type error.
|
||||||
|
The short way round:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
B=$(echo /opt/android-ndk/toolchains/llvm/prebuilt/*/bin)
|
||||||
|
CC=$B/aarch64-linux-android21-clang CXX=$B/aarch64-linux-android21-clang++ \
|
||||||
|
GOOS=android GOARCH=arm64 CGO_ENABLED=1 go build ./backend/...
|
||||||
|
```
|
||||||
|
|
||||||
|
**`CXX` is not optional.** Without it the oboe C++ sources in `oto`
|
||||||
|
compile against the host sysroot and fail on `android/log.h` and
|
||||||
|
`sys/system_properties.h`, which reads like a broken or missing NDK.
|
||||||
|
Restrict it to `./backend/...`: `./...` additionally builds
|
||||||
|
`build/android/gen`, a scaffold shim that only resolves inside the
|
||||||
|
wails task and fails with `undefined: main` on its own.
|
||||||
|
|
||||||
|
A Go method added to a bound service also reaches the frontend unless
|
||||||
|
it says not to — `//wails:ignore` above the func, which `make bindings`
|
||||||
|
then honours. `Player.SetDuck` is driven by OS audio focus and carries
|
||||||
|
one.
|
||||||
|
|
||||||
|
## The scaffold's own tasks
|
||||||
|
|
||||||
|
`build/android/Taskfile.yml` ships more than the Makefile wraps, and
|
||||||
|
they are the right thing to reach for when you want something one-off:
|
||||||
|
|
||||||
|
```
|
||||||
|
wails3 task android:run # debug build + emulator install + launch
|
||||||
|
wails3 task android:run:device # same, first connected physical device
|
||||||
|
wails3 task android:deploy-device # production APK to a device
|
||||||
|
wails3 task android:bundle:fat # AAB, for a Play Store upload
|
||||||
|
wails3 task android:studio # open build/android/ in Android Studio
|
||||||
|
wails3 task android:device:list
|
||||||
|
wails3 task android:logs:all
|
||||||
|
wails3 task android:clean
|
||||||
|
```
|
||||||
|
|
||||||
|
Two are deliberately **not** wrapped. `android:logs` greps logcat for
|
||||||
|
`(Wails|yellowjacket)`, which catches the `WailsBridge` tag but misses
|
||||||
|
the app's own process tag (`app.yellowjacket` — lowercase, so `Wails`
|
||||||
|
does not match it) and misses `ActivityManager`'s "has died" line, which
|
||||||
|
is the one that tells you it crashed; `make android-logs` filters by tag
|
||||||
|
instead. And `ensure-emulator` boots whatever `-list-avds | tail -1`
|
||||||
|
returns, with no pidfile and no boot wait, so it cannot be stopped or
|
||||||
|
sequenced.
|
||||||
|
|
||||||
|
## The identity is declared twice
|
||||||
|
|
||||||
|
`applicationId` in `build/android/app/build.gradle` is what Gradle
|
||||||
|
installs. `APP_ID` in `build/android/Taskfile.yml` is what every
|
||||||
|
adb-driven task uninstalls, launches and filters. **Nothing enforces
|
||||||
|
that they agree**, and `ANDROID.md`'s advice to set `APP_ID` in
|
||||||
|
`build/config.yml` does not work in beta.8 — `wails3 task` never reads
|
||||||
|
that file (verified with `--dry`), and even when set it feeds only the
|
||||||
|
adb commands, never Gradle. Change both or the official `run`/`deploy`
|
||||||
|
tasks address a package that is not installed.
|
||||||
|
|
||||||
|
Related, and it will bite once: the launcher activity is
|
||||||
|
`com.wails.app.MainActivity` and the applicationId is
|
||||||
|
`app.yellowjacket`. `am start -n app.yellowjacket/.MainActivity`
|
||||||
|
resolves the leading dot against the *applicationId* and fails with a
|
||||||
|
class-not-found that reads like a broken build. Always the
|
||||||
|
fully-qualified form.
|
||||||
|
|
||||||
|
## What only a device can answer
|
||||||
|
|
||||||
|
The emulator cannot run this app (three separate reasons, none of them
|
||||||
|
ours — see plan 016), so the phone in someone's pocket is a tier, and
|
||||||
|
asking for it is cheap. The first run of it, on 2026-08-17, confirmed
|
||||||
|
the whole of A4 and found two faults **no other tier can see**:
|
||||||
|
|
||||||
|
- **The back gesture.** `MainActivity.onBackPressed` asks
|
||||||
|
`webView.canGoBack()`. Nothing in a desktop shell has a back gesture,
|
||||||
|
so no spec had ever called `page.goBack()` and the app had never
|
||||||
|
pushed a history entry — back quit from any depth. It is a history
|
||||||
|
entry per navigation now, which is also what made it assertable in the
|
||||||
|
browser tier (`e2e/specs/back-navigation.spec.ts`).
|
||||||
|
- **The safe area.** `targetSdk 35` forces edge-to-edge, so the
|
||||||
|
transport and the tab bar sat under the gesture bar. **A browser
|
||||||
|
viewport has no system bars**: `phone-shell.spec.ts` at 390x844 will
|
||||||
|
keep passing on a build the device is clipping 48dp off. Insets are
|
||||||
|
handled in `applyWindowInsets()`.
|
||||||
|
|
||||||
|
So when asking for a device run, ask about what the platform *adds* —
|
||||||
|
system bars, the back gesture, focus and audio interruptions,
|
||||||
|
permission dialogs, the keyboard — not about what the app draws. The
|
||||||
|
drawing is what the other five tiers already cover.
|
||||||
|
|
||||||
|
## Asking the device, not just looking at it
|
||||||
|
|
||||||
|
A real phone can be inspected, and that turns this tier from "reported
|
||||||
|
symptoms" into evidence. Three commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make android-screenshot # what the screen shows (.dev/ by default)
|
||||||
|
make android-inspect # forward the WebView's devtools socket
|
||||||
|
make android-eval EXPR='JSON.stringify({vp:[innerWidth,innerHeight]})'
|
||||||
|
```
|
||||||
|
|
||||||
|
Four things about it, each of which costs an hour if met cold:
|
||||||
|
|
||||||
|
- **Only a `debuggable` build has a devtools socket**, and a debug build
|
||||||
|
carries `applicationIdSuffix ".dev"` so it installs **beside** the
|
||||||
|
release app. That matters more than convenience: the two are signed by
|
||||||
|
different certificates, and Android's only remedy for a changed
|
||||||
|
certificate is an uninstall, which takes the user's library with it.
|
||||||
|
Never uninstall to make room for a build.
|
||||||
|
- **Playwright cannot drive it.** `connectOverCDP` calls
|
||||||
|
`Browser.setDownloadBehavior`, a WebView answers "Browser context
|
||||||
|
management is not supported", and the connection dies before the first
|
||||||
|
evaluate. `scripts/android-eval.mjs` is raw CDP over Node's built-in
|
||||||
|
WebSocket for that reason.
|
||||||
|
- **Wireless adb drops when the screen sleeps.** The symptoms are
|
||||||
|
`device offline` mid-session and a `fetch failed` from the eval
|
||||||
|
script. Plug in over USB for anything longer than a couple of probes.
|
||||||
|
- **The socket name carries the pid**, which changes on every launch, so
|
||||||
|
it is resolved rather than remembered.
|
||||||
|
|
||||||
|
**And the reason to bother: the phone is an engine, not a screen.** The
|
||||||
|
first device here renders in **Chrome 113** at 424x439 CSS px. Every
|
||||||
|
other tier runs a current Chromium or WebKit, so a spec that passes at
|
||||||
|
that viewport says nothing about the phone — 113 has no Popover API and
|
||||||
|
no relaxed CSS nesting, and a dropped CSS declaration renders as
|
||||||
|
"present but wrong", which is the hardest failure to read from a
|
||||||
|
picture. Get the version first; it reframes every other symptom.
|
||||||
@@ -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
|
another tagger stamped after import kept `untagged` for ever and its
|
||||||
folder kept asking; `updateAudioFile` promotes it now, guarded on
|
folder kept asking; `updateAudioFile` promotes it now, guarded on
|
||||||
`untagged` so a deliberate `user_skipped_permanent` survives a rescan.
|
`untagged` so a deliberate `user_skipped_permanent` survives a rescan.
|
||||||
|
|
||||||
|
## Android cross-compiles, unchanged (measured 2026-08-16)
|
||||||
|
|
||||||
|
Plan 015's phase 0 gate, and it passed further than it was asked to: the
|
||||||
|
whole app builds for Android and produces a working 27 MB fat APK with
|
||||||
|
**no source changes at all**.
|
||||||
|
|
||||||
|
Environment: Arch's `android-ndk-26` (`/opt/android-ndk`, r26d /
|
||||||
|
26.3.11579264 — the pinned version), platform `android-35` and
|
||||||
|
build-tools 34.0.0 from `~/Android/Sdk`. Note that Arch's
|
||||||
|
`/opt/android-sdk` carries *no* platforms, so `ANDROID_HOME` has to
|
||||||
|
point at `~/Android/Sdk` for the Gradle half while `ANDROID_NDK_HOME`
|
||||||
|
points at `/opt/android-ndk` for the Go half.
|
||||||
|
|
||||||
|
```
|
||||||
|
export ANDROID_NDK_HOME=/opt/android-ndk
|
||||||
|
export ANDROID_HOME="$HOME/Android/Sdk" ANDROID_SDK_ROOT="$HOME/Android/Sdk"
|
||||||
|
cd frontend && pnpm build && cd .. # main.go embeds frontend/dist
|
||||||
|
PATH="$PWD/scripts/toolbin:$PATH" go tool wails3 task android:package:fat
|
||||||
|
```
|
||||||
|
|
||||||
|
Results, all first-try:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `libwails.so` arm64-v8a | 29.9 MB, production, stripped |
|
||||||
|
| `libwails.so` x86_64 | 31.8 MB, production, stripped |
|
||||||
|
| `bin/yellowjacket.apk` | 27.3 MB, both ABIs |
|
||||||
|
| Go compile, per ABI | ~9 s |
|
||||||
|
| Gradle assemble | ~13 s cold |
|
||||||
|
|
||||||
|
**The dependency that looked fatal is fine.** A `CGO_ENABLED=0` probe of
|
||||||
|
`./backend/... ./internal/...` for `android/arm64` compiles *everything*
|
||||||
|
except two packages, and both fail only because their Android
|
||||||
|
implementation is cgo: `ebitengine/oto/v3` (`driver_android.go` needs its
|
||||||
|
bundled **oboe** C++ backend) and `wails/v3/pkg/application` (the JNI
|
||||||
|
bridge). Both are exactly what the NDK supplies. `modernc.org/sqlite` —
|
||||||
|
the whole database layer, and the thing most likely to have no Android
|
||||||
|
target — is clean. Confirmed in the linked object rather than inferred:
|
||||||
|
`nm -D` shows `oto_oboe_Play` and the `oboe::` symbols, `readelf -d`
|
||||||
|
shows `libOpenSLES.so` as NEEDED, and the
|
||||||
|
`Java_com_wails_app_WailsBridge_native*` exports are present. The audio
|
||||||
|
backend is genuinely linked, not stubbed.
|
||||||
|
|
||||||
|
Four things found on the way that are not obvious:
|
||||||
|
|
||||||
|
- **`wails3 update build-assets` does not generate `build/android/`.** In
|
||||||
|
beta.8 it extracts only `internal/commands/updatable_build_assets`,
|
||||||
|
which is darwin/ios/linux/windows. The android tree comes from
|
||||||
|
`generate build-assets`, which extracts the *whole* asset FS and would
|
||||||
|
rewrite all of `build/`. So it was generated into a scratch dir and
|
||||||
|
`android/` copied across. CLAUDE.md claimed the refresh regenerates it;
|
||||||
|
that was wrong, and is corrected.
|
||||||
|
- **`update build-assets` does clobber nfpm's `homepage` and
|
||||||
|
`license`**, which `build/linux/nfpm/nfpm.yaml` says in a comment it
|
||||||
|
leaves alone. It reset them to `https://wails.io` and `MIT`. The
|
||||||
|
comment is wrong; those two fields need re-checking after any refresh.
|
||||||
|
- **The scaffold's `package:fat` shipped a debug arm64 library.**
|
||||||
|
`build` forwards `ARCH` to `compile:go:shared` but not `PRODUCTION`,
|
||||||
|
so the arm64 leg recomputed `BUILD_FLAGS` against an unset
|
||||||
|
`.PRODUCTION` and took the debug branch — while amd64, which
|
||||||
|
`package:fat` calls directly with `PRODUCTION: "true"`, was correct.
|
||||||
|
A release APK therefore carried a 40 MB unstripped debug library for
|
||||||
|
the phone ABI and a 31 MB production one for the emulator. Fixed in
|
||||||
|
`build/android/Taskfile.yml`, which is this repo's one edit to that
|
||||||
|
scaffold file and is commented as such. 34 MB APK before, 27 after.
|
||||||
|
- **The generated APK is not yet an identity.** `com.wails.app`,
|
||||||
|
`versionCode 1`, `versionName 1.0`, signed `CN=Android Debug`. That is
|
||||||
|
plan 015 phase 2 and none of it is a surprise, but it is worth knowing
|
||||||
|
that the scaffold happily produces an installable-once,
|
||||||
|
never-updatable APK by default.
|
||||||
|
|
||||||
|
**Not established:** that it *runs*. There is no AVD or system image on
|
||||||
|
this machine and no device attached, so nothing has launched the APK.
|
||||||
|
Every runtime concern plan 015 lists as out of scope is still out of
|
||||||
|
scope and still real — MPRIS in particular is compiled *in*, because
|
||||||
|
Go's `android` GOOS implies the `linux` build tag.
|
||||||
|
|
||||||
|
## The Android build runs, and stops on one line (measured 2026-08-16)
|
||||||
|
|
||||||
|
The APK installs and launches on an emulator. `libwails.so` loads, the
|
||||||
|
JNI bridge comes up — and the process is gone six milliseconds later.
|
||||||
|
|
||||||
|
**The cause is `backend/system/buildUserDirPath`.** It switches on
|
||||||
|
`runtime.GOOS` with cases for `darwin`, `linux` and `windows` and a
|
||||||
|
`default:` returning `errUnsupportedOS`. `runtime.GOOS` is `"android"`,
|
||||||
|
so it takes the default, `NewYellowJacketApp` fails, and `main()` calls
|
||||||
|
`os.Exit(1)`. `YJ_HOME` overrides that path on every OS, so an
|
||||||
|
`android` case pointing at the app-private directory is the shape of
|
||||||
|
the fix. It is the *first* thing that stops it, not the only one.
|
||||||
|
|
||||||
|
**What cost the time was not finding the bug, it was that the failure
|
||||||
|
is invisible in all three places you would look.** Worth knowing before
|
||||||
|
meeting it:
|
||||||
|
|
||||||
|
- **Go's stdout does not reach logcat.** An app's fd 1 and 2 go to
|
||||||
|
`/dev/null`, so the `slog` line naming the error is discarded.
|
||||||
|
`setprop log.redirect-stdio true` does not help — that redirects the
|
||||||
|
*Java* runtime's `System.out`, not a c-shared native library's.
|
||||||
|
- **`os.Exit` leaves no evidence.** No panic, no `AndroidRuntime`
|
||||||
|
stack, nothing in `/data/tombstones`, nothing in `logcat -b crash` or
|
||||||
|
dropbox. The only signal present is `Zygote: exited due to signal 9`,
|
||||||
|
which reads as "the system killed it" and sends you looking at the
|
||||||
|
low-memory killer.
|
||||||
|
- **ActivityManager restarts it faster than you can observe it.**
|
||||||
|
`pidof` always answers and `am start` always says `Status: ok`, so
|
||||||
|
the app looks alive while crash-looping several times a second. The
|
||||||
|
honest check is whether it is the *same pid* a few seconds later,
|
||||||
|
which is what `make android-smoke` asserts.
|
||||||
|
|
||||||
|
The tell is `I/WailsBridge: Wails bridge initialized` followed
|
||||||
|
immediately by a new pid doing the same thing.
|
||||||
|
|
||||||
|
**Emulator environment**, which is not the obvious one on Arch: Gradle
|
||||||
|
needs a *platform*, and `/opt/android-sdk` (the `android-sdk` package)
|
||||||
|
has an NDK and build-tools but an empty `platforms/`. So `ANDROID_HOME`
|
||||||
|
points at `~/Android/Sdk` (user-owned, where sdkmanager writes) while
|
||||||
|
`ANDROID_NDK_HOME` points at `/opt/android-ndk` — two SDKs, one for
|
||||||
|
each half of the build. The image is
|
||||||
|
`system-images;android-35;google_apis;x86_64` (~3.5 GB with the
|
||||||
|
emulator sdkmanager pulls alongside it): `google_apis` rather than
|
||||||
|
`default` because this is a WebView app and that image carries the
|
||||||
|
Chrome-based WebView. KVM is present and usable here; without it a 30 s
|
||||||
|
boot becomes tens of minutes, which reads as a hung target.
|
||||||
|
|
||||||
|
Operating all of this is `scripts/android-emulator.sh` and the
|
||||||
|
`make android-*` targets, documented in
|
||||||
|
`.pi/skills/yellowjacket-dev/references/android-tier.md`.
|
||||||
|
|
||||||
|
## What the Wails v3 Android docs say, and where they are wrong (2026-08-16)
|
||||||
|
|
||||||
|
Read after phase 0, before phase 2. Sources: `ANDROID.md` shipped inside
|
||||||
|
`wails/v3@v3.0.0-beta.8` (authoritative for our exact version) and
|
||||||
|
`v3.wails.io/guides/mobile/*`.
|
||||||
|
|
||||||
|
**Two claims in `ANDROID.md` are wrong for beta.8, and both were
|
||||||
|
checked.** Its Configuration section says to put `APP_ID: com.example.
|
||||||
|
myapp` in `build/config.yml` and that this "controls the package name".
|
||||||
|
Neither half holds. `wails3 task` builds its variable set from CLI
|
||||||
|
`KEY=VALUE` arguments and the Taskfile tree and **never reads
|
||||||
|
`config.yml`** (`internal/commands/task.go`); adding `APP_ID` there and
|
||||||
|
running `android:run:device --dry` still emits
|
||||||
|
`am start -n com.wails.app/`. And `APP_ID` feeds only the adb commands
|
||||||
|
in the android Taskfile — uninstall, launch, log filter — never Gradle,
|
||||||
|
whose `applicationId` is a literal in `app/build.gradle`. So the
|
||||||
|
identity is necessarily declared **twice** and nothing enforces
|
||||||
|
agreement. Both are set now, each with a comment pointing at the other.
|
||||||
|
|
||||||
|
**The fix for the crash we found is a documented API.**
|
||||||
|
`application.Mobile.StoragePath()` returns the app's private internal
|
||||||
|
files directory (`getFilesDir()` on Android, Application Support on
|
||||||
|
iOS) and — the useful part — is **build-tag-free**: `mobile.go` declares
|
||||||
|
the interface and `mobile_stub.go` returns `""` on desktop. Since
|
||||||
|
`resolveUserDirPath` already lets `YJ_HOME` override the path on every
|
||||||
|
OS, the whole fix is to set that override from `StoragePath()` early in
|
||||||
|
`main()` when it is non-empty. No `//go:build` split, no new import in
|
||||||
|
`backend/system` (which must stay Wails-free — the `indexbuild` tag
|
||||||
|
split exists for exactly that), and desktop behaviour is untouched
|
||||||
|
because the stub returns empty.
|
||||||
|
|
||||||
|
The same section gives the general rule: branch on
|
||||||
|
`application.System.IsMobile()` / `IsPlatform(application.PlatformAndroid)`
|
||||||
|
rather than build tags, because it compiles everywhere.
|
||||||
|
|
||||||
|
**`android` implies `linux` is documented**, which confirms rather than
|
||||||
|
discovers the MPRIS problem: `//go:build linux` files are in the Android
|
||||||
|
build and desktop-Linux-only ones need `linux && !android`.
|
||||||
|
|
||||||
|
**A finding for the runtime plan, not this one: the folder picker does
|
||||||
|
not exist on Android.** Open-*directory* dialogs "return an error — SAF
|
||||||
|
yields tree URIs, not filesystem paths", and save-file dialogs likewise.
|
||||||
|
This app's entire first run is "choose your music folder", and its
|
||||||
|
library model is filesystem paths. That is a design problem, not a
|
||||||
|
porting detail, and it is larger than the data-directory one.
|
||||||
|
|
||||||
|
**The scaffold ships its own android tasks**, and they are worth knowing
|
||||||
|
before writing anything: `android:run`, `run:device`, `deploy-emulator`,
|
||||||
|
`deploy-device`, `package`, `package:fat`, `bundle`/`bundle:fat` (AAB
|
||||||
|
for Play), `studio`, `device:list`, `logs`, `logs:all`, `clean`, and an
|
||||||
|
internal `ensure-emulator`. `make android-*` deliberately does not wrap
|
||||||
|
most of them. Two reasons it does not just use `android:logs`: that task
|
||||||
|
greps logcat for `(Wails|yellowjacket)`, which matches the `WailsBridge`
|
||||||
|
tag but **not** the app's own process tag (`app.yellowjacket`, lowercase)
|
||||||
|
and **not** `ActivityManager`'s "has died" line — the one that tells you
|
||||||
|
it crashed. And `ensure-emulator` takes whatever `-list-avds | tail -1`
|
||||||
|
returns, with no pidfile and no boot wait, so it cannot be stopped or
|
||||||
|
sequenced by a Makefile.
|
||||||
|
|
||||||
|
Two smaller things. Debug builds log framework diagnostics to logcat
|
||||||
|
under the `Wails` tag and are inspectable from `chrome://inspect`;
|
||||||
|
production builds compile that out — so a debug APK is the more
|
||||||
|
informative one when something is wrong. And the docs recommend
|
||||||
|
`build-tools;35.0.0`; 34.0.0 is what is installed here and builds fine.
|
||||||
|
|
||||||
|
## The app starts on Android; x86_64 Android cannot run it (2026-08-16)
|
||||||
|
|
||||||
|
Two findings, and the second is the one with consequences.
|
||||||
|
|
||||||
|
**The startup bug is fixed.** `backend/system`'s `buildUserDirPath`
|
||||||
|
switched on `runtime.GOOS` and Android took the `default:` branch, so
|
||||||
|
`main()` called `os.Exit(1)` six milliseconds after the JNI bridge came
|
||||||
|
up. `main()` now calls
|
||||||
|
`system.UseHomeOverride(application.Mobile.StoragePath())` before
|
||||||
|
anything asks for a path. `StoragePath()` is `getFilesDir()` on
|
||||||
|
Android, Application Support on iOS and `""` on desktop — where
|
||||||
|
`UseHomeOverride` is a no-op — so the change needs no build tag and
|
||||||
|
alters nothing off mobile. `backend/system` gained no import of the
|
||||||
|
Wails application package, deliberately: that is the same constraint
|
||||||
|
the `indexbuild` split protects in `backend/events`.
|
||||||
|
|
||||||
|
**And then it takes SIGSYS on the x86_64 emulator.**
|
||||||
|
|
||||||
|
```
|
||||||
|
F/libc: Fatal signal 31 (SIGSYS), code 1 (SYS_SECCOMP), syscall 6
|
||||||
|
F/DEBUG: Cause: seccomp prevented call to disallowed x86_64 system call 6
|
||||||
|
```
|
||||||
|
|
||||||
|
Syscall 6 on x86_64 is `lstat`, and the caller is **not our code and
|
||||||
|
not Go's**. Go's `syscall` package already routes both `Stat` and
|
||||||
|
`Lstat` through `fstatat` on amd64 *and* arm64. The caller is
|
||||||
|
`modernc.org/libc`, which `modernc.org/sqlite` sits on and therefore
|
||||||
|
the entire database layer: `libc_linux_amd64.go`'s `Xlstat64` issues
|
||||||
|
`unix.Syscall(unix.SYS_LSTAT, …)` directly. Android's seccomp filter
|
||||||
|
forbids it because bionic never issues it.
|
||||||
|
|
||||||
|
**arm64 is unaffected, structurally rather than by luck.** arm64 has no
|
||||||
|
`lstat` syscall at all, so `ccgo_linux_arm64.go`'s `Xlstat` is
|
||||||
|
`Xfstatat(…, AT_SYMLINK_NOFOLLOW)` → `SYS_newfstatat` (79), which is
|
||||||
|
permitted. `grep -c SYS_LSTAT ccgo_linux_arm64.go` returns 0 against 1
|
||||||
|
for amd64.
|
||||||
|
|
||||||
|
Three consequences:
|
||||||
|
|
||||||
|
- **The default emulator cannot verify this app.** `make android-smoke`
|
||||||
|
on an x86_64 AVD reports a tombstone that says nothing about your
|
||||||
|
change. Verification needs an `arm64-v8a` image (full software
|
||||||
|
emulation on an x86_64 host, so slow) or a real device.
|
||||||
|
- **The x86_64 half of the fat APK is dead weight on every Android**,
|
||||||
|
not just emulators — an x86 Chromebook would hit exactly this. It is
|
||||||
|
31 MB of a 27 MB compressed artifact. Dropping it is a real option;
|
||||||
|
keeping it costs size and buys an emulator target that does not work.
|
||||||
|
Not decided here.
|
||||||
|
- The failure is at least *legible*. Unlike the `os.Exit` it replaced,
|
||||||
|
SIGSYS leaves a tombstone with a backtrace into `libwails.so`, which
|
||||||
|
is how it was identified in one pass.
|
||||||
|
|
||||||
|
Worth knowing for anything else that reaches for a pure-Go C library:
|
||||||
|
this class of bug is invisible to every build and every desktop test,
|
||||||
|
and appears only under a platform's syscall filter.
|
||||||
|
|
||||||
|
### …and the arm64 emulator is not an option on an x86_64 host
|
||||||
|
|
||||||
|
Emulator 37.1.11 refuses outright, after the 3.8 GB image download:
|
||||||
|
|
||||||
|
```
|
||||||
|
FATAL | Avd's CPU Architecture 'arm64' is not supported by the QEMU2
|
||||||
|
emulator on x86_64 host. System image must match the host
|
||||||
|
architecture.
|
||||||
|
```
|
||||||
|
|
||||||
|
Google dropped cross-architecture emulation and there is no flag for
|
||||||
|
it. So the arm64 claim above rests on reading modernc's two code paths,
|
||||||
|
not on having run it: verifying the shipped ABI needs an arm64 host, a
|
||||||
|
physical device, or `adb connect` to one. The image was deleted again;
|
||||||
|
do not re-download it.
|
||||||
|
|
||||||
|
## Android media controls need no new JNI and no new dependency (2026-08-16)
|
||||||
|
|
||||||
|
Plan 016's A4 — playback that survives the screen locking — turned out
|
||||||
|
to be reachable entirely through seams that already exist, which is the
|
||||||
|
finding worth keeping. The obvious blocker is that Wails' `androidBridge*`
|
||||||
|
helpers are unexported, so Go cannot call arbitrary Java. It does not
|
||||||
|
need to:
|
||||||
|
|
||||||
|
- **Go → Java** is `application.Android.StartForegroundService(json)`,
|
||||||
|
which *is* exported, and `build/android/` is our tree — so widening
|
||||||
|
the JSON that `WailsBridge.startForegroundService` accepts is a local
|
||||||
|
edit, not a fork of the runtime.
|
||||||
|
- **Java → Go** is `WailsBridge.emitEvent(name, json)` →
|
||||||
|
`nativeEmitEvent` → `app.Event.Emit`, which a Go `app.Event.On`
|
||||||
|
subscriber receives with `Data` as a `map[string]any`.
|
||||||
|
|
||||||
|
So the handler is one JSON document out and one command event back, and
|
||||||
|
`backend/mediacontrols`' existing `Handler`/`Callbacks` interface — written
|
||||||
|
for MPRIS — needed one addition (`OnDuck`) to cover a MediaSession.
|
||||||
|
|
||||||
|
**The Java side needs no androidx.media either.** `MediaSessionCompat`
|
||||||
|
is the documented route, but `android.media.session.MediaSession` and
|
||||||
|
`Notification.MediaStyle` are both API 21 and minSdk here is 21, so the
|
||||||
|
platform API covers it with two `Build.VERSION` branches (the channel,
|
||||||
|
and PendingIntent mutability flags) and no new Gradle dependency.
|
||||||
|
|
||||||
|
Four things measured or reasoned along the way, each of which would
|
||||||
|
have been a bug:
|
||||||
|
|
||||||
|
- **From API 26 the framework ducks the app itself** and sends no
|
||||||
|
`AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK`. So a duck implemented in the
|
||||||
|
player is a *pre-Oreo* path, and `setWillPauseWhenDucked(true)` —
|
||||||
|
which is how you get the callback back — would mean pausing for
|
||||||
|
every notification tone. Implementing both attenuates twice.
|
||||||
|
- **A duck must not touch the user's volume.** `Player.SetDuck` holds
|
||||||
|
the attenuation as a separate offset and re-applies the user's level
|
||||||
|
through `setVolumeLocked`, so it cannot accumulate across repeated
|
||||||
|
ducks and `getUserVolume` — which feeds the event, the persisted
|
||||||
|
state and every relative change — still reports what the user chose.
|
||||||
|
- **From Android 12 a background app may not *start* a foreground
|
||||||
|
service**, but it may keep delivering intents to one already running.
|
||||||
|
Every update after the first is exactly that case (a track change
|
||||||
|
with the screen off), so `WailsBridge` picks `startService` over
|
||||||
|
`startForegroundService` once `WailsForegroundService.running` is set.
|
||||||
|
- **A service started with `startForegroundService` that returns from
|
||||||
|
`onStartCommand` without calling `startForeground` is killed**, so
|
||||||
|
the transport-button intents call it too rather than only the payload
|
||||||
|
path.
|
||||||
|
|
||||||
|
**`make lint` does not see any of this.** Its three passes are the app,
|
||||||
|
`indexbuild` and `dev` tag sets, all on linux/amd64, and `android.go` is
|
||||||
|
behind the `android` build tag — the only thing that compiles it is the
|
||||||
|
cross-compiler in `make android`. That is why the payload keys, the
|
||||||
|
state words and the command names live in `androidpayload.go` *without*
|
||||||
|
a build tag, with a test: it is the half that can be checked on the
|
||||||
|
machine doing the work. A quick manual check of the tagged half is
|
||||||
|
|
||||||
|
```bash
|
||||||
|
B=$(echo /opt/android-ndk/toolchains/llvm/prebuilt/*/bin)
|
||||||
|
CC=$B/aarch64-linux-android21-clang CXX=$B/aarch64-linux-android21-clang++ \
|
||||||
|
GOOS=android GOARCH=arm64 CGO_ENABLED=1 go build ./backend/...
|
||||||
|
```
|
||||||
|
|
||||||
|
— `CXX` matters: without it the oboe C++ sources compile against the
|
||||||
|
host sysroot and fail on `android/log.h`, which reads like a missing NDK.
|
||||||
|
|
||||||
|
**None of it has run.** The APK builds for both ABIs and the Go and Java
|
||||||
|
halves compile; everything above about behaviour is read from the
|
||||||
|
Android documentation and the source. The x86_64 emulator still cannot
|
||||||
|
run this app (modernc `lstat`/seccomp, above) and an arm64 AVD still
|
||||||
|
cannot exist on an x86_64 host, so A4's first real test is a device.
|
||||||
|
|
||||||
|
## Dropping x86_64 cut the APK by 41% (measured 2026-08-16)
|
||||||
|
|
||||||
|
Plan 016's B1, decided: the ABI is gone.
|
||||||
|
|
||||||
|
| | fat (arm64 + x86_64) | arm64 only |
|
||||||
|
|---|---|---|
|
||||||
|
| `bin/yellowjacket.apk` | 27,059,130 B | 15,898,465 B |
|
||||||
|
| `lib/` entries | 2 | 1 |
|
||||||
|
|
||||||
|
It buys nothing to keep. x86_64 Android takes SIGSYS the first time it
|
||||||
|
touches the database (modernc's raw `lstat` against Android's seccomp
|
||||||
|
filter, above), which is *every* x86_64 device — emulators and x86
|
||||||
|
Chromebooks alike — not merely the emulator here.
|
||||||
|
|
||||||
|
Three places had to agree, and the third is the one that would have
|
||||||
|
made this a silent no-op: `abiFilters` in `build/android/app/
|
||||||
|
build.gradle` (what Gradle packages), `android:package` rather than
|
||||||
|
`android:package:fat` in the Makefile (what Go compiles — otherwise the
|
||||||
|
31 MB library is still built and then discarded), and the `native-code`
|
||||||
|
assertion in `android-apk.yml`'s Verify step, which is now
|
||||||
|
`native-code: 'arm64-v8a'$` and fails if a second ABI ever comes back.
|
||||||
|
The anchor is deliberate and was checked against a real artifact:
|
||||||
|
without it the pattern also matches the fat APK's line.
|
||||||
|
|
||||||
|
One consequence for the dev tier was written down before it was
|
||||||
|
checked, and checking it proved it false — see the next entry.
|
||||||
|
|
||||||
|
## arm64 translation runs Go until Go asks the CPU what it is (measured 2026-08-16)
|
||||||
|
|
||||||
|
Predicted, when the x86_64 ABI was dropped: `make android-install`
|
||||||
|
against the emulator would now fail with
|
||||||
|
`INSTALL_FAILED_NO_MATCHING_ABIS`. **Measured: it installs and
|
||||||
|
launches.** Google's `google_apis` x86_64 images carry arm64
|
||||||
|
translation —
|
||||||
|
|
||||||
|
```
|
||||||
|
ro.product.cpu.abilist = x86_64,arm64-v8a
|
||||||
|
```
|
||||||
|
|
||||||
|
— so the loader maps `lib/arm64/libwails.so` and executes it; the
|
||||||
|
tombstone confirms it with `ABI: 'x86_64'` / `Guest architecture:
|
||||||
|
'arm64'`.
|
||||||
|
|
||||||
|
It dies anyway, before a line of our code, and the instruction says
|
||||||
|
exactly why. The fault is at `libwails.so+0x15911d0`:
|
||||||
|
|
||||||
|
```
|
||||||
|
signal 4 (SIGILL), code -6 (SI_TKILL)
|
||||||
|
15911d0: d5380600 mrs x0, ID_AA64ISAR0_EL1
|
||||||
|
```
|
||||||
|
|
||||||
|
That is Go's `internal/cpu` reading the arm64 feature-ID system
|
||||||
|
register during runtime init. The translator does not implement it, so
|
||||||
|
**no Go binary starts under it** — this is not a property of this app
|
||||||
|
and no work here would change it. (`code -6 (SI_TKILL)` also means the
|
||||||
|
signal was re-raised by the process itself: Go's handler caught the
|
||||||
|
SIGILL, printed a traceback to a stdout that goes to `/dev/null`, and
|
||||||
|
re-raised. The invisible-failure rule again.)
|
||||||
|
|
||||||
|
So there are now three distinct ways this app fails on an x86_64
|
||||||
|
Android, none of them a bug in it:
|
||||||
|
|
||||||
|
| build | cause | signal |
|
||||||
|
|---|---|---|
|
||||||
|
| x86_64 | modernc's raw `lstat` vs seccomp | SIGSYS, syscall 6 |
|
||||||
|
| arm64, translated | Go reads `ID_AA64ISAR0_EL1` | SIGILL |
|
||||||
|
| arm64, real device | — | still unverified |
|
||||||
|
|
||||||
|
**A physical arm64 device is still the only verification path**, which
|
||||||
|
is the conclusion the previous session reached by a different route.
|
||||||
|
The value of this entry is that it closes the remaining plausible
|
||||||
|
shortcut, with the instruction that closes it.
|
||||||
|
|
||||||
|
### Two bugs the attempt found in the harness itself
|
||||||
|
|
||||||
|
Both were on `main`, and the first had made the whole tier unusable
|
||||||
|
since the commit that added it.
|
||||||
|
|
||||||
|
**`scripts/android-emulator.sh` did not parse.** A `case` pattern read
|
||||||
|
`*signatures do not match*)`, and `do` is a reserved word: bash fails
|
||||||
|
the parse of the *entire file*, so `make android-emulator`,
|
||||||
|
`android-install`, `android-smoke` and `android-logs` all died with
|
||||||
|
`line 190: syntax error near unexpected token 'do'`. Quoting the inner
|
||||||
|
words fixes it. A shell script that is only run interactively can carry
|
||||||
|
a syntax error indefinitely — `bash -n` in the pre-commit hook would
|
||||||
|
have caught it, and does not exist.
|
||||||
|
|
||||||
|
**A bare `adb` addresses whatever is attached.** With a second emulator
|
||||||
|
present (another project's, or a stale `offline` entry from a previous
|
||||||
|
run), every adb call fails with "more than one device", and
|
||||||
|
`cmd_install` reported that as *"no device — run 'make
|
||||||
|
android-emulator' first"* — directly after that had printed "waiting
|
||||||
|
for boot ok". `pick_device` now resolves `ANDROID_SERIAL` from
|
||||||
|
`ro.boot.qemu.avd_name`, since serials are assigned in boot order and
|
||||||
|
the AVD name is the stable identity. Verified with both emulators
|
||||||
|
running: it selects `yj-test` and installs.
|
||||||
|
|
||||||
|
## The phone shell fits, and what it cost to make it fit (2026-08-16)
|
||||||
|
|
||||||
|
Plan 016 B2, phase 1: the shell below 600px. Measured at 360×780 and
|
||||||
|
390×844 against the real app (`make dev-headless` + Playwright, which
|
||||||
|
is the tier that can answer this — server mode serves the same document
|
||||||
|
an Android WebView renders).
|
||||||
|
|
||||||
|
**What overflowed, and by how much.** The body was 652px wide in a
|
||||||
|
360px viewport before any of this. Walking every element and its shadow
|
||||||
|
roots for a `right` past the viewport named the causes in order:
|
||||||
|
|
||||||
|
| element | width | why |
|
||||||
|
|---|---|---|
|
||||||
|
| `header.top-bar` | 580 | its children's minimums, summed |
|
||||||
|
| `search-bar` | 320 | `.search-container { min-width: 200px }` |
|
||||||
|
| `job-indicator` | 157 | the label, "3 background jobs" |
|
||||||
|
|
||||||
|
A `min-width` in a flex row is a *hard* floor — it does not shrink — and
|
||||||
|
a grid item's implicit minimum is `auto`, i.e. its content. So the
|
||||||
|
header could not get smaller than the sum of what it held, the body grew
|
||||||
|
to the header, and `overflow-x: hidden` would then have hidden a third
|
||||||
|
of the app rather than fitting it. `min-width: 0` on the boxes between
|
||||||
|
the viewport and the content, plus each component standing its own
|
||||||
|
non-essential parts down in its own stylesheet, takes 360 → 360 exactly.
|
||||||
|
At 320px (400% zoom, the width WCAG 1.4.10 names) it is also exact.
|
||||||
|
|
||||||
|
**So an existing spec now asserts the opposite of what it did**, and
|
||||||
|
that is the fix landing rather than the test being weakened.
|
||||||
|
`layout-overflow.spec.ts` used to assert that the 464px of app behind
|
||||||
|
`overflow: hidden` *could be scrolled to* with a wheel gesture, which
|
||||||
|
was the remedy available when the shell had one layout. It reflows now,
|
||||||
|
which is what 1.4.10 asks for; scrolling to the overflow was the
|
||||||
|
concession.
|
||||||
|
|
||||||
|
**And a shared component brings its test handles with it.**
|
||||||
|
`bottom-nav`'s "More" opens the *existing* `<app-sidebar>` in a drawer —
|
||||||
|
the whole point being not to write a second list of destinations — but
|
||||||
|
rendering it unconditionally put a second `data-testid="nav-home"` (and
|
||||||
|
ten siblings) in the DOM. **30 existing specs failed** with "strict mode
|
||||||
|
violation: resolved to 2 elements", on a *desktop* viewport where
|
||||||
|
`bottom-nav` is `display: none` and the drawer can never open. Lazy
|
||||||
|
rendering fixes it; the component test asserts the absence, because the
|
||||||
|
failure is invisible from inside the component and appears in files
|
||||||
|
nobody touched.
|
||||||
|
|
||||||
|
Three smaller things worth keeping:
|
||||||
|
|
||||||
|
- **A new icon name is a runtime failure, not a build one.** `bars` was
|
||||||
|
not in `src/icons/names.txt`, so `offline-icons.spec.ts` caught it —
|
||||||
|
the sweep asserts `window.__yjIconMisses` is empty. `node
|
||||||
|
frontend/scripts/fetch-icons.mjs` re-vendors after adding a line.
|
||||||
|
- **A `wa-drawer` animates, so a test asserts its events**, not its
|
||||||
|
`open` property: setting `open = false` starts a hide that has not
|
||||||
|
finished on the next microtask, and a test reading the property in
|
||||||
|
between sees the state it is leaving.
|
||||||
|
- **`update(el)` in the component tier takes two arguments**
|
||||||
|
(`update(el, {})`), which is only visible from `tsc`, not from a
|
||||||
|
failing test.
|
||||||
|
|
||||||
|
### The local e2e tier was not running the same app CI runs
|
||||||
|
|
||||||
|
`requested-badge.spec.ts` failed two of three tests locally while CI was
|
||||||
|
green, and the reason is worth more than the fix: **`dev-headless.sh`
|
||||||
|
was the only place that did not neutralise `YJ_CORE_INDEX_URL`.**
|
||||||
|
`seed-sandbox.sh` and `ci.yml` both point it at `127.0.0.1:1`; the dev
|
||||||
|
launcher did not, so the app downloaded and built the real ~1M-row
|
||||||
|
Explore catalog into the run's `YJ_HOME`, and a local `make e2e` then
|
||||||
|
ran against a world CI never sees.
|
||||||
|
|
||||||
|
Found by reading the failure screenshot: the spec had searched Explore
|
||||||
|
for its fixture album and the page was full of *real* ones — Real
|
||||||
|
Estate, Arrested Youth, The Yes Album. The staged row was there and
|
||||||
|
invisible among a million others.
|
||||||
|
|
||||||
|
`dev-headless.sh` now defaults the variable to the dead address and
|
||||||
|
takes an explicit one if you want the real catalog for exploring by
|
||||||
|
hand. `make e2e` locally: 97 passed / 3 failed before, 100 passed
|
||||||
|
after.
|
||||||
|
|
||||||
|
The second half of the same problem is that **the backend is one shared
|
||||||
|
process with one database, and specs leave rows in it.**
|
||||||
|
`explore-shelves` staged its catalog only `IfEmpty`, so a single album
|
||||||
|
row left behind by `requested-badge` satisfied that gate, the shelves
|
||||||
|
were drawn from one foreign row, and the artist card the spec clicks did
|
||||||
|
not exist. It fails on the *second* local run and passes on the first,
|
||||||
|
which is the least useful order, and never in CI, where every run gets a
|
||||||
|
fresh `YJ_HOME`.
|
||||||
|
|
||||||
|
"Is the catalog empty" was the wrong question; "are my rows there" is
|
||||||
|
the right one. The staging is unconditional now (`INSERT OR IGNORE`
|
||||||
|
keyed on the MBID) and the assertion moved from *this insert wrote a
|
||||||
|
row* to *every fixture row is present* — which is both idempotent and a
|
||||||
|
stronger check, since an MBID failing `CHECK(length(mbid) = 16)` is
|
||||||
|
silently dropped by OR IGNORE and would otherwise show up as an empty
|
||||||
|
page rather than a failed setup.
|
||||||
|
|
||||||
|
**Verified: the full suite runs twice against the same app, 100 passed
|
||||||
|
both times.** That is the property to keep — a spec tier whose second
|
||||||
|
run differs from its first is a tier that will one day blame the wrong
|
||||||
|
commit.
|
||||||
|
|
||||||
|
## A media query adds no specificity, and dead CSS looks like working CSS (2026-08-16)
|
||||||
|
|
||||||
|
Plan 016 B2 phase 2 shipped the full-screen now-playing view, and
|
||||||
|
checking it with a screenshot found that **phase 1's shell rules had
|
||||||
|
never applied**.
|
||||||
|
|
||||||
|
`index.css` is base rules then component rules, and the phone block had
|
||||||
|
been inserted in the middle — above the plain `.top-bar` and `.title`
|
||||||
|
rules it meant to override. A media query is not a specificity boost,
|
||||||
|
so with equal specificity the *later* declaration wins. Measured at
|
||||||
|
390px before the fix:
|
||||||
|
|
||||||
|
| declared for the phone | actually computed |
|
||||||
|
|---|---|
|
||||||
|
| `padding-left: 0.75em` | 32px (the 2em base) |
|
||||||
|
| `gap: 0.5em` | 16px (base) |
|
||||||
|
| `font-size: 1.1em` | 24px (the 1.5em base) |
|
||||||
|
| `grid-template-columns: minmax(0,1fr) auto auto` | `320px 1fr auto` (base) |
|
||||||
|
|
||||||
|
After moving the block to the end of the file: 12px, 8px, 17.6px, and
|
||||||
|
`154px 187px 33px`.
|
||||||
|
|
||||||
|
**Nothing failed while they were dead**, which is the part worth
|
||||||
|
keeping. The phone spec asserts that the shell does not scroll
|
||||||
|
sideways, and it did not — because the fitting was being done by
|
||||||
|
`min-width: 0` and by each component's *own* media query, which live in
|
||||||
|
their own stylesheets and so had no later rule to lose to. The
|
||||||
|
declarations that did nothing were the cosmetic ones, and no assertion
|
||||||
|
was ever going to see them. A screenshot did, in about ten seconds.
|
||||||
|
|
||||||
|
The file now ends with one phone section, and says why it is last.
|
||||||
|
|
||||||
|
### What the same screenshot found about the view itself
|
||||||
|
|
||||||
|
The bottom bar was still rendering the mini player *underneath* the
|
||||||
|
full-screen view — 4em of a 844px phone spent saying exactly what the
|
||||||
|
view above it says, and invisible to every assertion about either one
|
||||||
|
(both were correct on their own). `index.css` hides `.bottom-bar` while
|
||||||
|
`#main-content[data-active-view="now-playing"]`, through `:has()`
|
||||||
|
rather than a class toggled from `index.ts`: which view is showing is
|
||||||
|
already published as an attribute, and a second expression of the same
|
||||||
|
fact is a second thing to keep in step.
|
||||||
|
|
||||||
|
That took the queue button away with it, since that button lives in the
|
||||||
|
bar — so the view carries its own, toggling the same `open` attribute
|
||||||
|
on the same panel element.
|
||||||
|
|
||||||
|
**And a css`` literal cannot contain a backtick.** A comment reading
|
||||||
|
"the track size is set on the `wa-slider` inside its shadow root"
|
||||||
|
terminates the tagged template, and the failure arrives as
|
||||||
|
`Expected "]" but found "wa"` from the CSS parser, at a line number in
|
||||||
|
the *comment*. `make css-check` exists for this and named it
|
||||||
|
immediately.
|
||||||
|
|
||||||
|
## The index artifact could not be exported, and the reason is a rule this repo already had (2026-08-16)
|
||||||
|
|
||||||
|
`maintain-index` failed on an unrelated push:
|
||||||
|
|
||||||
|
```
|
||||||
|
indexexport: copy rows: SQL logic error: no such column: total_tracks (1)
|
||||||
|
```
|
||||||
|
|
||||||
|
Three minutes in, on the one job that owns the ~205 GB checkpoint and
|
||||||
|
publishes the catalog every user downloads.
|
||||||
|
|
||||||
|
**The cause is the exception that keeps that checkpoint alive.** The
|
||||||
|
index job's `/cache` is a real `YJ_HOME` that survives between runs, so
|
||||||
|
`explore_index` there is classified `Cache` and is deliberately *not*
|
||||||
|
dropped and recreated by `cmd/indexbuild`'s schema repair
|
||||||
|
(`staleschema.go`). A column added to the schema afterwards is
|
||||||
|
therefore simply absent from that database — and `total_tracks` was
|
||||||
|
added by the album-completeness work. The exporter selected it anyway.
|
||||||
|
|
||||||
|
**The fix is the rule the importer already follows.**
|
||||||
|
`artifactHasTotals()` exists precisely because "adding a column to the
|
||||||
|
importer's SELECT is how you break every artifact already published";
|
||||||
|
the mirror image — *reading* an index older than the binary — had no
|
||||||
|
such guard. `sourceColumns()` asks
|
||||||
|
`pragma_table_info('explore_index', 'main')` and selects a literal `0`
|
||||||
|
when the column is not there, which is what the column already means by
|
||||||
|
"the catalog does not say" and what the app already renders as unknown
|
||||||
|
rather than as incomplete. The destination keeps every column, so an
|
||||||
|
importer needs no second shape.
|
||||||
|
|
||||||
|
So the pattern generalises, and is worth stating once: **any query that
|
||||||
|
crosses a version boundary in either direction asks the schema rather
|
||||||
|
than trusting it.** There are now three of these — `artifactStoresText`
|
||||||
|
(encoding), `artifactHasTotals` (import), `sourceColumns` (export).
|
||||||
|
|
||||||
|
Two things about the test are worth keeping.
|
||||||
|
|
||||||
|
It reproduces the failure **symptom first**: with the fix removed it
|
||||||
|
fails with the CI message verbatim, `copy rows: SQL logic error: no
|
||||||
|
such column: total_tracks (1)`. That was checked, not assumed.
|
||||||
|
|
||||||
|
And its first version silently proved nothing. `oldColumns` was
|
||||||
|
`strings.Replace(catalogColumns, "total_tracks, ", "", 1)` — which
|
||||||
|
matches *nothing*, because the list is formatted across lines and the
|
||||||
|
name is followed by a newline rather than a space. So the "old" index
|
||||||
|
had every current column, the probe correctly said so, and the only
|
||||||
|
reason this was caught is that the assertion about the probe ran before
|
||||||
|
the assertion about the export. A fixture built by string surgery on a
|
||||||
|
formatted constant needs to be whitespace-independent; it filters the
|
||||||
|
list now.
|
||||||
|
|
||||||
|
## Long-press is one document listener, and the header row is a row (2026-08-17)
|
||||||
|
|
||||||
|
Plan 016 B2 phase 3. A phone has no right-click, and every context menu
|
||||||
|
in this app opens from a `contextmenu` event — six components' worth,
|
||||||
|
bound three different ways (delegated on a virtualizer, per row, per
|
||||||
|
card). `frontend/src/utils/long-press.ts` is one document-capture
|
||||||
|
listener installed once from `index.ts`: a touch that holds still for
|
||||||
|
500 ms dispatches a synthetic `contextmenu` at the touch point, and
|
||||||
|
**every existing handler runs unchanged**. No component opted in, and
|
||||||
|
none can forget to.
|
||||||
|
|
||||||
|
Four things it has to get right, and each is a way the obvious version
|
||||||
|
fails:
|
||||||
|
|
||||||
|
- **The target is `composedPath()[0]`, not `elementFromPoint`**, which
|
||||||
|
stops at the outermost shadow host. Every menu here is bound inside
|
||||||
|
one, so a host-targeted event reaches a delegated listener and no
|
||||||
|
per-row one.
|
||||||
|
- **A browser that fires its own must win.** Chromium already dispatches
|
||||||
|
`contextmenu` on long-press; WebKit and the WebView vary. One arriving
|
||||||
|
during the press cancels ours; one arriving after ours is swallowed at
|
||||||
|
document capture.
|
||||||
|
- **Ours is told from theirs by identity** (a `WeakSet`), not by
|
||||||
|
`isTrusted`. `isTrusted` would work in the app and is untestable — no
|
||||||
|
test can dispatch a trusted event — so the suppression path would have
|
||||||
|
been the one thing with no coverage.
|
||||||
|
- **The click ending the gesture is swallowed**, keyed on the gesture
|
||||||
|
(cleared by the next `pointerdown`) rather than a time window, or a
|
||||||
|
quick tap on the menu that just opened is eaten too.
|
||||||
|
|
||||||
|
**What cost the time was the assertion, not the code.** The e2e spec
|
||||||
|
pressed `[role="row"]` — which is the *column header*, and it is the
|
||||||
|
first one. The gesture fired correctly, the header correctly ignored it,
|
||||||
|
and the failure looked exactly like a menu that would not open. Found by
|
||||||
|
probing the running app (`playwright-cli eval`, dispatching the same
|
||||||
|
pointer events and logging what saw the `contextmenu`), which showed the
|
||||||
|
event reaching the row's own listener with no menu behind it — i.e. the
|
||||||
|
handler was refusing it, not missing it. `.track-row` is the selector.
|
||||||
|
|
||||||
|
Verified by execution: 8 component tests (real browser, real shadow
|
||||||
|
boundary, real timings) and 2 e2e specs against the running app, twice
|
||||||
|
in a row. Not verified: any of it under a real finger on a real
|
||||||
|
WebView — the pointer events are dispatched, because neither Desktop
|
||||||
|
Chrome nor Desktop Safari has touch and there is no device tier.
|
||||||
|
|
||||||
|
## The first device run: A4 works, and two things only a phone could say (2026-08-17)
|
||||||
|
|
||||||
|
The published v1.5.0 APK, on a real phone, owner-reported. **This is the
|
||||||
|
first runtime evidence any of the Android work has ever had** — A4
|
||||||
|
shipped entirely reasoned from source.
|
||||||
|
|
||||||
|
**What holds.** Playback survives the screen locking. The MediaSession
|
||||||
|
notification appears in the status pane *with album art* — which
|
||||||
|
answers, in one observation, four of the open questions from plan 016:
|
||||||
|
the foreground service starts, POST_NOTIFICATIONS was granted and the
|
||||||
|
notification is visible, the session is picked up, and **cover art
|
||||||
|
decoded from a `MANAGE_EXTERNAL_STORAGE` path by a service is
|
||||||
|
readable**. The last was the one nobody could argue from documentation.
|
||||||
|
|
||||||
|
**Two bugs, and neither is visible from any tier we have.**
|
||||||
|
|
||||||
|
*Back did not navigate back.* The scaffold's
|
||||||
|
`MainActivity.onBackPressed` asks `webView.canGoBack()` and finishes the
|
||||||
|
activity otherwise — and this app had never touched `history`, so that
|
||||||
|
was false at every depth and back quit from anywhere. The fix is in the
|
||||||
|
frontend, not in Java: a navigation is a `history` entry now
|
||||||
|
(`recordNavigation` in `index.ts`, same URL, the destination in the
|
||||||
|
entry's state) and `popstate` replays it with `_isBack`. The Java half
|
||||||
|
needs no change, because the mechanism it already uses is the one we
|
||||||
|
were failing to feed.
|
||||||
|
|
||||||
|
Two rules keep it honest. The **first** navigation replaces the launch
|
||||||
|
entry rather than pushing one, or every launch costs a back press before
|
||||||
|
the app will close. And the in-app back buttons go through
|
||||||
|
`history.back()` rather than popping a stack of their own — `navStack`
|
||||||
|
is **deleted**, not kept alongside, because two stacks is exactly how
|
||||||
|
the detail view's own button and the phone's gesture come to disagree
|
||||||
|
about how far back one press goes. `back-navigation.spec.ts` pins that
|
||||||
|
invariant.
|
||||||
|
|
||||||
|
*The transport was off screen.* **`targetSdk 35` is Android 15, which
|
||||||
|
lays every app out edge-to-edge**, ignores the deprecated
|
||||||
|
`statusBarColor`/`navigationBarColor` the theme still sets, and hands
|
||||||
|
the app a window the size of the screen. The WebView is `match_parent`,
|
||||||
|
so the page's bottom band — the transport, and on a phone the tab bar —
|
||||||
|
was drawn underneath the gesture bar. `applyWindowInsets()` pads the
|
||||||
|
container by `systemBars | displayCutout | ime` and returns the insets
|
||||||
|
rather than consuming them. The window background goes black to match
|
||||||
|
the app's own ramp, or the padding shows as a blue-grey band.
|
||||||
|
|
||||||
|
**Neither is findable in the browser tier, and that is the lesson worth
|
||||||
|
keeping**: a viewport has no system bars, so `phone-shell.spec.ts` at
|
||||||
|
390x844 renders a shell that fits perfectly while the device cuts 48dp
|
||||||
|
off the bottom — and `page.goBack()` was never called because nothing in
|
||||||
|
a desktop shell has a back gesture. The Android tier's own note says
|
||||||
|
failure there is invisible; this is the milder version, where the app
|
||||||
|
works and is simply wrong in ways only the platform can show you.
|
||||||
|
|
||||||
|
Verified by execution: the APK builds with the Java change; 3 e2e specs
|
||||||
|
cover the history behaviour, on Chromium locally and WebKit in CI.
|
||||||
|
Not verified: the insets themselves, which need the next APK on the
|
||||||
|
owner's phone. What to look for is one thing — the transport and the tab
|
||||||
|
bar clear of the gesture bar, and the header clear of the status bar.
|
||||||
|
|
||||||
|
## The phone is a Chrome 113 WebView, and that reframes everything (2026-08-17)
|
||||||
|
|
||||||
|
The device is reachable over adb now, so the tier can be *asked* rather
|
||||||
|
than reported on. `make android-inspect` + `make android-eval` are that:
|
||||||
|
a debug build (`applicationIdSuffix ".dev"`, so it installs **beside**
|
||||||
|
the release app rather than needing the uninstall that would take the
|
||||||
|
library with it) opens `webview_devtools_remote_<pid>`, and raw CDP over
|
||||||
|
Node's built-in WebSocket evaluates in the real page. **Playwright
|
||||||
|
cannot do this** — `connectOverCDP` calls `Browser.setDownloadBehavior`
|
||||||
|
and a WebView answers "Browser context management is not supported",
|
||||||
|
killing the connection before the first evaluate.
|
||||||
|
|
||||||
|
Measured on the device (Light Phone III, TLP301):
|
||||||
|
|
||||||
|
| fact | value |
|
||||||
|
| --- | --- |
|
||||||
|
| Android | 14, SDK 34 |
|
||||||
|
| screen | 1080x1240, density 408 |
|
||||||
|
| WebView viewport | **424 x 439 CSS px**, DPR 2.55 |
|
||||||
|
| WebView engine | **Chrome 113.0.5672.136** (mid-2023) |
|
||||||
|
|
||||||
|
**The first correction: the insets commit does not explain the report.**
|
||||||
|
Edge-to-edge is forced for apps *running on* Android 15, and this phone
|
||||||
|
is Android 14 — the screenshot shows the app correctly inset, with the
|
||||||
|
status bar and the gesture bar outside it. `applyWindowInsets()` is
|
||||||
|
right and stays (the next phone, or one OS update, is Android 15), but
|
||||||
|
it is **pre-emptive, not the fix for "the controls are off screen"**.
|
||||||
|
That was an inference from a version number, and the device disagreed.
|
||||||
|
|
||||||
|
**The second correction: the black `fill` proves nothing.** A wa-icon on
|
||||||
|
the device has the right `color` (#ffd43b) and an `<svg>` in its shadow
|
||||||
|
root, and `getComputedStyle(svg).fill` is black — but that is the *svg
|
||||||
|
root*, and every vendored Font Awesome path carries
|
||||||
|
`fill="currentColor"` itself, so the root's fill is irrelevant. Measuring
|
||||||
|
the wrong node produced a diagnosis-shaped result. `__yjIconMisses` is
|
||||||
|
empty, so no name is unbundled either. Why the icons do not appear in the
|
||||||
|
screenshot is **still open**.
|
||||||
|
|
||||||
|
**What the engine version does explain, and what to check next.**
|
||||||
|
Chrome 113 has `:has()`, `color-mix()` and `dialog.showModal()`, and
|
||||||
|
lacks three things this app's dependencies use:
|
||||||
|
|
||||||
|
- **Relaxed CSS nesting** (Chrome 120): a nested rule starting with a
|
||||||
|
bare element selector is dropped. `.x { svg { ... } }` parses to
|
||||||
|
nothing; `.x { & svg { ... } }` parses. Any Web Awesome or app
|
||||||
|
stylesheet written the modern way silently loses declarations here,
|
||||||
|
and dropped declarations are exactly the failure that looks like
|
||||||
|
"rendered but wrong".
|
||||||
|
- **The Popover API** (Chrome 114). Web Awesome's popup calls
|
||||||
|
`showPopover?.()` — optional, so nothing throws — but also sets
|
||||||
|
`popover="manual"`, which on 113 is an unknown attribute doing
|
||||||
|
nothing. Every context menu, dropdown and the whole menu keyboard
|
||||||
|
model rides on that, so it is the first thing to test with a library
|
||||||
|
present.
|
||||||
|
- `light-dark()` and relative colour syntax (`rgb(from ...)`).
|
||||||
|
|
||||||
|
**The lesson for the tier: a device is an engine, not just a screen.**
|
||||||
|
Every browser tier here runs a current Chromium or WebKit, and the phone
|
||||||
|
that will actually run this app is two years behind — so "it renders at
|
||||||
|
424x439 in Chromium" (checked, the transport is on screen) says nothing
|
||||||
|
about whether it renders on the phone. The e2e tier cannot be fixed by
|
||||||
|
resizing; the missing signal is version, and CDP against the device is
|
||||||
|
the only place to get it.
|
||||||
|
|
||||||
|
Verified by execution: every number in the table, the four feature
|
||||||
|
probes, and that the hardware back button no longer kills the app (the
|
||||||
|
`.dev` build carries the history fix; pid survived a BACK press).
|
||||||
|
Unverified: what happened to the icons and the transport controls, which
|
||||||
|
is where this resumes.
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
# 015 — Android release pipeline
|
||||||
|
|
||||||
|
Ship an Android APK from CI on every version tag, published to the Gitea
|
||||||
|
generic package registry so Obtainium can poll a plain URL.
|
||||||
|
|
||||||
|
The baseline is `~/Development/ljos`, whose `.gitea/workflows/ci.yml`
|
||||||
|
`android:` job has been through the failure modes already. Most of what
|
||||||
|
follows is a transcription of that job onto this repo's conventions;
|
||||||
|
where it differs, the difference is argued.
|
||||||
|
|
||||||
|
## What this is not
|
||||||
|
|
||||||
|
**This ships a pipeline, not a usable Android music player.** The
|
||||||
|
success criterion is a signed, installable APK that launches — not an
|
||||||
|
app anyone would want. Explicitly out of scope, and each is real:
|
||||||
|
|
||||||
|
- `backend/mediacontrols/mpris_linux.go` **will be compiled on Android**.
|
||||||
|
Go's `android` GOOS implies the `linux` build tag, so the `//go:build
|
||||||
|
linux` file is in the build and MPRIS will look for a session bus that
|
||||||
|
does not exist. It compiles; it will error at runtime.
|
||||||
|
- `backend/system` resolves XDG paths. Android has no XDG.
|
||||||
|
- The explore catalog artifact is ~0.6 GB. Nothing on a phone wants that.
|
||||||
|
- The shell is a desktop shell: an eleven-item sidebar, a 800×600
|
||||||
|
measured minimum, a transport bar. None of that is a phone layout.
|
||||||
|
- The library scanner walks a filesystem Android does not grant.
|
||||||
|
|
||||||
|
Those are the *next* plan, if there is one. Conflating them with this one
|
||||||
|
is how a build pipeline takes six weeks.
|
||||||
|
|
||||||
|
## Phase 0 — the gate [DONE 2026-08-16]
|
||||||
|
|
||||||
|
**Passed, further than asked.** No source changes were needed; a full
|
||||||
|
27 MB fat APK built first try, both ABIs, production-stripped. Numbers,
|
||||||
|
the environment and four non-obvious findings are in
|
||||||
|
`.planning/NOTES.md` — including a scaffold bug that put a *debug*
|
||||||
|
library in the release APK's phone ABI, fixed here.
|
||||||
|
|
||||||
|
**It also installs and launches on an emulator, and then exits.** One
|
||||||
|
line stops it: `backend/system/buildUserDirPath` switches on
|
||||||
|
`runtime.GOOS` and Android takes the `default:` branch returning
|
||||||
|
`errUnsupportedOS`, so `main()` hits `os.Exit(1)` six milliseconds
|
||||||
|
after the JNI bridge comes up. That is the *first* thing that stops it,
|
||||||
|
not the only one — see the "not this" section above, all of which is
|
||||||
|
still true and still out of scope.
|
||||||
|
|
||||||
|
The emulator tier that found it is now part of the harness:
|
||||||
|
`scripts/android-emulator.sh`, the `make android-*` targets, and
|
||||||
|
`.pi/skills/yellowjacket-dev/references/android-tier.md`. It exists
|
||||||
|
because the failure is invisible in all three places anyone would look
|
||||||
|
(no panic, no tombstone, no crash buffer) and ActivityManager restarts
|
||||||
|
the app fast enough that `pidof` always answers — so the tier's
|
||||||
|
assertion is "same pid after N seconds", not "it started".
|
||||||
|
|
||||||
|
Original phase 0 text follows, kept because its reasoning is what the
|
||||||
|
later phases rest on.
|
||||||
|
|
||||||
|
|
||||||
|
Everything downstream is wasted if the c-shared link fails. Establish it
|
||||||
|
by hand, locally, before writing a line of YAML.
|
||||||
|
|
||||||
|
Already established, by probe rather than by assumption:
|
||||||
|
|
||||||
|
```
|
||||||
|
GOOS=android GOARCH=arm64 CGO_ENABLED=0 go build ./backend/... ./internal/...
|
||||||
|
```
|
||||||
|
|
||||||
|
compiles the entire tree. Exactly two packages fail, and both fail only
|
||||||
|
because their Android implementation is cgo:
|
||||||
|
|
||||||
|
- `ebitengine/oto/v3` — `driver_android.go` needs the bundled **oboe**
|
||||||
|
C++ backend. Oto supports Android natively; there is no Java audio
|
||||||
|
glue to write.
|
||||||
|
- `wails/v3/pkg/application` — `mobile_features_android.go` needs the
|
||||||
|
JNI bridge.
|
||||||
|
|
||||||
|
`modernc.org/sqlite` (the whole database layer), `beep`, `godbus` and
|
||||||
|
every `backend/` package are clean. **No source changes are known to be
|
||||||
|
required**, which is the single most surprising finding here and the
|
||||||
|
reason this plan is worth doing at all.
|
||||||
|
|
||||||
|
What Phase 0 must actually verify:
|
||||||
|
|
||||||
|
1. Install NDK **r26d** (`26.3.11579264`) locally. Pinned, not "whatever
|
||||||
|
sdkmanager gives you" — ljos's AGENTS.md records newer NDKs breaking
|
||||||
|
this build.
|
||||||
|
2. Generate the scaffolding (Phase 1) and run
|
||||||
|
`wails3 task android:compile:go:shared ARCH=arm64` by hand.
|
||||||
|
3. Confirm `build/android/app/src/main/jniLibs/arm64-v8a/libwails.so`
|
||||||
|
exists and is an ARM64 shared object.
|
||||||
|
4. Repeat for `amd64` (the emulator ABI).
|
||||||
|
|
||||||
|
**If the link fails, stop and re-plan.** The likely culprits, in order:
|
||||||
|
alsa (oto must select oboe, not ALSA — if it reaches for `alsa.pc` the
|
||||||
|
build tags are wrong), and `main.go`'s `//go:embed all:frontend/dist`
|
||||||
|
combined with the generated `main_android.gen.go` overlay.
|
||||||
|
|
||||||
|
Deliverable: a note in `.planning/NOTES.md` recording the exact command
|
||||||
|
and the NDK version that produced a `.so`, or the reason it cannot.
|
||||||
|
|
||||||
|
## Phase 1 — un-ignore and commit the Android scaffolding [DONE]
|
||||||
|
|
||||||
|
Done as a side-effect of phase 0, which could not run without it. One
|
||||||
|
correction to the text below: **step 1 is wrong.** `update
|
||||||
|
build-assets` does not generate the android tree (NOTES.md explains);
|
||||||
|
it was generated with `generate build-assets` into a scratch dir and
|
||||||
|
`android/` copied across. CLAUDE.md is corrected to match. Steps 2-5
|
||||||
|
were done as written.
|
||||||
|
|
||||||
|
|
||||||
|
`build/android/` is gitignored (`.gitignore:72`) and its `includes:`
|
||||||
|
entry was dropped from `Taskfile.yml` during plan 009. That was correct
|
||||||
|
when nothing could target Android and is what has to be undone.
|
||||||
|
|
||||||
|
1. `wails3 task common:update:build-assets` — beta.8 embeds
|
||||||
|
`internal/commands/build_assets/android/`, so this generates the tree.
|
||||||
|
2. Remove `build/android/` from `.gitignore`; add `build/ios/`'s reason
|
||||||
|
to a comment so the asymmetry is explained rather than looking like an
|
||||||
|
oversight.
|
||||||
|
3. Add `android: ./build/android/Taskfile.yml` to `Taskfile.yml`'s
|
||||||
|
`includes:`.
|
||||||
|
4. **Gitignore the tree's own output**, or the repo grows a few hundred
|
||||||
|
Gradle intermediates. ljos has exactly this problem — its
|
||||||
|
`app/build/android/app/build/**` is committed. Ignore:
|
||||||
|
- `build/android/app/build/`
|
||||||
|
- `build/android/app/src/main/jniLibs/`
|
||||||
|
- `build/android/overlay.json` and `build/android/gen/`
|
||||||
|
5. `make build-prod` and `make test` still pass — the new include must
|
||||||
|
not perturb the desktop path.
|
||||||
|
|
||||||
|
**The refresh hazard has to be written down.** CLAUDE.md's Packaging
|
||||||
|
section already says `build/`'s platform metadata is regenerated from
|
||||||
|
`build/config.yml` and hand edits are lost. Phase 2 edits `build.gradle`
|
||||||
|
by hand. Extend that paragraph to name `build/android/app/build.gradle`
|
||||||
|
specifically, because the loss is silent and the symptom (a debug-signed
|
||||||
|
APK) appears months later as a failed update.
|
||||||
|
|
||||||
|
## Phase 2 — make the APK identifiable and updatable [DONE 2026-08-16]
|
||||||
|
|
||||||
|
**Narrower than planned, because beta.8's scaffold is ahead of ljos's
|
||||||
|
beta.3: the release signing config already exists** and reads the four
|
||||||
|
`ANDROID_KEYSTORE_*` variables with a debug-keystore fallback. So this
|
||||||
|
phase was identity and versioning only. Verified end to end:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| package | `app.yellowjacket` (was `com.wails.app`) |
|
||||||
|
| versionCode / versionName | `10301` / `1.3.1`, from `YJ_VERSION_CODE` / `YJ_VERSION` |
|
||||||
|
| label | `YellowJacket` |
|
||||||
|
| signing | throwaway keystore -> `Signer #1 DN: CN=YellowJacket Test`, not the debug key |
|
||||||
|
| ABIs | arm64-v8a + x86_64, both production-stripped |
|
||||||
|
|
||||||
|
Installs and launches under the new identity. Still exits on the known
|
||||||
|
`buildUserDirPath` bug, which is phase 0's finding and not this phase's.
|
||||||
|
|
||||||
|
Two things this phase learned that the text below did not know:
|
||||||
|
|
||||||
|
- **The identity has to be declared twice.** `applicationId` in
|
||||||
|
`app/build.gradle` is what Gradle installs; `APP_ID` in
|
||||||
|
`build/android/Taskfile.yml` is what every adb-driven task targets.
|
||||||
|
`ANDROID.md` says to set `APP_ID` in `build/config.yml` — that does
|
||||||
|
nothing in beta.8, verified with `--dry`. Both are set, each
|
||||||
|
commented pointing at the other.
|
||||||
|
- **The launcher activity is not under the applicationId.** It stays
|
||||||
|
`com.wails.app.MainActivity` (the scaffold's Java package), so
|
||||||
|
`am start -n app.yellowjacket/.MainActivity` resolves the dot against
|
||||||
|
the wrong package and fails. `scripts/android-emulator.sh` carries the
|
||||||
|
fully-qualified name and a comment saying why.
|
||||||
|
|
||||||
|
The `keytool` PKCS12 note below was confirmed verbatim: given a
|
||||||
|
`-keypass` differing from `-storepass` it prints "Different store and
|
||||||
|
key passwords not supported for PKCS12 KeyStores. Ignoring
|
||||||
|
user-specified -keypass value."
|
||||||
|
|
||||||
|
Original phase 2 text follows.
|
||||||
|
|
||||||
|
|
||||||
|
Edit `build/android/app/build.gradle`, following ljos's, whose comments
|
||||||
|
are worth reading before writing this:
|
||||||
|
|
||||||
|
- `applicationId "app.yellowjacket"` — matches `config.yml`'s
|
||||||
|
`productIdentifier`. The `namespace` stays `com.wails.app` (it is the
|
||||||
|
Java package, not the app identity).
|
||||||
|
- `versionCode Integer.parseInt(System.getenv("YJ_VERSION_CODE") ?: "1")`
|
||||||
|
— **`Integer.parseInt`, not `(...) as Integer`**. Groovy binds the
|
||||||
|
parentheses to `versionCode` first, so the cast reads as
|
||||||
|
`versionCode("1") as Integer`, which sets a String and then casts the
|
||||||
|
setter's null return; Gradle fails the whole project with "Value is
|
||||||
|
null" at that line.
|
||||||
|
- `versionName System.getenv("YJ_VERSION") ?: "0.0.0"`.
|
||||||
|
- `abiFilters 'arm64-v8a', 'x86_64'`.
|
||||||
|
- A `release` signing config reading `ANDROID_KEYSTORE_FILE` /
|
||||||
|
`_PASSWORD` / `ANDROID_KEY_ALIAS` / `ANDROID_KEY_PASSWORD`, falling
|
||||||
|
back to the debug keystore only when no keystore is supplied.
|
||||||
|
|
||||||
|
**Android orders releases by an integer and refuses anything not greater
|
||||||
|
than what is installed.** A hardcoded `versionCode 1` means the first
|
||||||
|
install is the last: every later build is rejected as a downgrade and the
|
||||||
|
only fix is an uninstall. `1.3.1 -> 10301`, monotonic as long as minor
|
||||||
|
and patch stay under 100.
|
||||||
|
|
||||||
|
**Signing is not optional past the first install.** Android refuses to
|
||||||
|
update an app whose signing key changed, and the debug keystore differs
|
||||||
|
between every machine and every runner — so an unsigned CI build is a
|
||||||
|
decision to reinstall by hand forever. The job must **refuse to build**
|
||||||
|
without the keystore rather than quietly produce an APK that can never be
|
||||||
|
updated.
|
||||||
|
|
||||||
|
There is **one password and two required secrets**. keytool has defaulted
|
||||||
|
to PKCS12 since JDK 9 regardless of the `.jks` extension, and PKCS12
|
||||||
|
cannot hold a separate key password — given `-keypass` it warns and
|
||||||
|
ignores it. So `ANDROID_KEY_PASSWORD` defaults to the store password and
|
||||||
|
`ANDROID_KEY_ALIAS` to `yellowjacket`. Asking for a second password that
|
||||||
|
cannot exist is how someone sets a wrong value and debugs Gradle at
|
||||||
|
midnight.
|
||||||
|
|
||||||
|
Add `make android` → `PATH="$(TOOLBIN):$$PATH" go tool wails3 task
|
||||||
|
android:package:fat`, beside `build-prod`. `make skill-check` fails on a
|
||||||
|
documented target that does not exist, so document it only once it does.
|
||||||
|
|
||||||
|
## Phase 3 — the workflow [DONE 2026-08-16]
|
||||||
|
|
||||||
|
`.gitea/workflows/android-apk.yml`, plus `docs/android-release.md` as
|
||||||
|
the operating document its error messages point at (phase 4's
|
||||||
|
documentation half; the secrets themselves still have to be created by
|
||||||
|
hand — see the table there).
|
||||||
|
|
||||||
|
Three departures from the text below, all argued in the file:
|
||||||
|
|
||||||
|
- **No `continue-on-error`.** The plan inherited it from ljos, where
|
||||||
|
the Android job shares a pipeline with a server deploy that must
|
||||||
|
never go red over a phone build. Here it is standalone and can
|
||||||
|
neither delay nor redden anything, so a release step that fails
|
||||||
|
silently is strictly worse than one that fails visibly.
|
||||||
|
- **No cached `wails3` binary.** The plan budgeted for ljos's
|
||||||
|
`tools-bin` copy. Unnecessary: the CLI is a vendored `go tool`, and
|
||||||
|
the runner already bind-mounts `GOCACHE`/`GOMODCACHE` for every job,
|
||||||
|
so it is warm from `ci.yml`'s own `make bindings-check`. The GTK and
|
||||||
|
WebKit *dev* headers are still installed, because `go tool wails3`
|
||||||
|
links them.
|
||||||
|
- **A fourth cache volume, `/cache/gradle`.** Not in the plan and worth
|
||||||
|
~700 MB a run.
|
||||||
|
|
||||||
|
Four publish-gates were added and each was checked against a real APK:
|
||||||
|
both ABIs present, `versionCode` equal to the one derived from the tag,
|
||||||
|
a non-empty artifact, and **not signed with the debug key** — verified
|
||||||
|
by pointing the check at a deliberately debug-signed build, which it
|
||||||
|
refused.
|
||||||
|
|
||||||
|
Rehearsed locally with the exact CI invocation
|
||||||
|
(`make android ANDROID_SDK=... ANDROID_NDK=...`, `YJ_VERSION`,
|
||||||
|
`YJ_VERSION_CODE`, a throwaway keystore): `app.yellowjacket`,
|
||||||
|
versionCode 10301, versionName 1.3.1, label YellowJacket, both ABIs,
|
||||||
|
`Signer #1 DN: CN=YellowJacket`. Not yet run on the runner.
|
||||||
|
|
||||||
|
Original phase 3 text follows.
|
||||||
|
|
||||||
|
|
||||||
|
New file: `.gitea/workflows/android-apk.yml`. **Not a job in `ci.yml`.**
|
||||||
|
`ci.yml` runs on every branch push and is the workflow that gates; the
|
||||||
|
runner is capacity 1, and a 45-minute Android build in it would put every
|
||||||
|
push behind an SDK download.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
```
|
||||||
|
|
||||||
|
This is where the baseline genuinely diverges. ljos computes its version
|
||||||
|
in CI (`scripts/next-version.sh`) and gates the Android job on
|
||||||
|
`needs.release.outputs.version != ''`, with an `always()` whose absence
|
||||||
|
would silently kill the manual path. **This repo has no release
|
||||||
|
automation** — tags are pushed by hand and `homebrew-formula.yml` already
|
||||||
|
keys on `v*`. So there is no `needs:`, no `always()`, and no status
|
||||||
|
function to get wrong: the tag *is* the version, and a dispatch falls
|
||||||
|
back to `git describe --tags --abbrev=0`.
|
||||||
|
|
||||||
|
Container, matching `ci.yml`'s conventions (`ubuntu:24.04`, clone by hand
|
||||||
|
with `PACKAGE_TOKEN` rather than `actions/checkout`, which is a JS action
|
||||||
|
needing node before any step has installed it):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
container:
|
||||||
|
image: ubuntu:24.04
|
||||||
|
volumes:
|
||||||
|
- /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool
|
||||||
|
- /home/logan/docker/gitea/data/runner/cache/android-sdk:/cache/android-sdk
|
||||||
|
```
|
||||||
|
|
||||||
|
The SDK path must be inside the runner's `valid_volumes` allowlist —
|
||||||
|
a directory outside it makes the job **fail to start**, not silently skip
|
||||||
|
the mount. `/cache/tool` is already allowed and already holds the Go
|
||||||
|
toolchain `ci.yml` downloads.
|
||||||
|
|
||||||
|
`continue-on-error: true` and `timeout-minutes: 45`. Advisory, because a
|
||||||
|
tag's other three workflows must not go red over a phone build, and a
|
||||||
|
backstop because a wedged SDK download must not hold the only runner slot
|
||||||
|
for hours.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
|
||||||
|
1. **System packages.** `ci.yml`'s set plus `unzip` and `openjdk-17-jdk`.
|
||||||
|
`libasound2-dev` stays — it is for the *host* `wails3` build, not the
|
||||||
|
Android cross-build, which uses oboe.
|
||||||
|
2. **Go toolchain** — reuse `ci.yml`'s `/cache/tool/go` block verbatim.
|
||||||
|
3. **Android SDK and NDK (cached).** ljos's `install_if_missing`
|
||||||
|
idempotent guard, unchanged: cmdline-tools 11076708, `platform-tools`,
|
||||||
|
`platforms;android-34`, `build-tools;34.0.0`, `ndk;26.3.11579264`.
|
||||||
|
sdkmanager is itself idempotent but still spends minutes verifying,
|
||||||
|
which is why the explicit directory guards are there. ~3 GB and most of
|
||||||
|
the job's wall clock on the first run; a directory listing after.
|
||||||
|
4. **wails3.** Cheaper here than in ljos, which pins
|
||||||
|
`go install …/wails3@$version` against `app/go.mod`. This repo vendors
|
||||||
|
the CLI (`go tool wails3`, `scripts/toolbin/wails3`), so the version is
|
||||||
|
already pinned by `go.mod` and there is nothing to drift. It still
|
||||||
|
*links* GTK and WebKit, so cache the built binary in
|
||||||
|
`/cache/android-sdk/tools-bin` keyed on the wails version — and note
|
||||||
|
ljos's finding that **caching the binary alone turned a slow job into
|
||||||
|
a broken one**: `wails3` is dynamically linked, so the runtime
|
||||||
|
packages are needed even on a cache hit. Here they are already in
|
||||||
|
step 1.
|
||||||
|
5. **Frontend + codegen.** `pnpm install --frozen-lockfile && pnpm build`
|
||||||
|
(pnpm, not ljos's npm), then `make generate`. `main.go` embeds
|
||||||
|
`frontend/dist`, so nothing Go-side typechecks without it.
|
||||||
|
6. **Decode the keystore.** Refuse to build if `ANDROID_KEYSTORE_B64` is
|
||||||
|
unset, with the sentence explaining why (Phase 2). Decide the absolute
|
||||||
|
path *here* and export it via `$GITHUB_ENV` — **`${{ env.HOME }}`
|
||||||
|
evaluates to an empty string in Gitea's expression context**, which
|
||||||
|
turned `$HOME/x.jks` into `/x.jks` and surfaced as a missing file
|
||||||
|
fifty-five seconds into a Gradle run.
|
||||||
|
7. **Build.** Compute `YJ_VERSION_CODE` from the tag, verify the keystore
|
||||||
|
opens with `keytool -list` *before* Gradle does (Gradle only notices at
|
||||||
|
`:app:validateSigningRelease`, a minute in, and reports it as a missing
|
||||||
|
file), then `make android`.
|
||||||
|
8. **Verify the signature.** `apksigner verify --print-certs`, and print
|
||||||
|
the SHA-256 with the note that a change to it breaks every future
|
||||||
|
update. **Nothing here pipes into `head`**: under `set -o pipefail`,
|
||||||
|
`head -1` exits early, the producer takes SIGPIPE, and the step fails
|
||||||
|
with 141 *after* printing a perfectly good APK. Use `find … -print
|
||||||
|
-quit` and a captured variable.
|
||||||
|
9. **Publish** to `api/packages/${OWNER}/generic/yellowjacket-android`,
|
||||||
|
authenticating `--user "${OWNER}:${PACKAGE_TOKEN}"` — the same
|
||||||
|
credential pair `arch-package.yml` already uses, not ljos's
|
||||||
|
`REGISTRY_USER`/`REGISTRY_TOKEN`. Two copies: a versioned one for
|
||||||
|
history and a fixed `latest/yellowjacket.apk` that Obtainium watches.
|
||||||
|
Gitea refuses to overwrite, so delete `latest` first. The generic
|
||||||
|
registry is readable **without credentials**, which is what lets
|
||||||
|
Obtainium poll a plain URL with no token and no public source mirror.
|
||||||
|
|
||||||
|
## Phase 4 — secrets and documentation
|
||||||
|
|
||||||
|
Secrets to create on the repo (all under Settings → Actions → Secrets):
|
||||||
|
|
||||||
|
| Secret | Required | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| `ANDROID_KEYSTORE_B64` | yes | `base64 -w0 yellowjacket-release.jks` |
|
||||||
|
| `ANDROID_KEYSTORE_PASSWORD` | yes | |
|
||||||
|
| `ANDROID_KEY_ALIAS` | no | defaults to `yellowjacket` |
|
||||||
|
| `ANDROID_KEY_PASSWORD` | no | defaults to the store password |
|
||||||
|
| `PACKAGE_TOKEN` | already exists | used by `arch-package.yml` |
|
||||||
|
|
||||||
|
Write the keytool command, the Obtainium URL and the signing-key warning
|
||||||
|
into a docs page — this is the part of ljos's setup that lives in
|
||||||
|
`docs/clients.md` and is referenced from the workflow's error messages,
|
||||||
|
so the messages have somewhere to point.
|
||||||
|
|
||||||
|
Then extend CLAUDE.md's CI section: it currently says "four workflows,
|
||||||
|
three of them package and publish; only `ci.yml` gates". That becomes
|
||||||
|
five, with the same sentence still true.
|
||||||
|
|
||||||
|
## Order and stopping points
|
||||||
|
|
||||||
|
Phase 0 gates everything. Phases 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.
|
||||||
@@ -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
|
||||||
|
`<first-run-wizard>` intercepts all pointer events until a library
|
||||||
|
exists — so the app is not merely empty, it is inert.
|
||||||
|
|
||||||
|
Whatever A1 resolves to decides this: a MediaStore library needs no
|
||||||
|
picker at all, and a SAF tree needs the picker to return a URI the
|
||||||
|
backend can use.
|
||||||
|
|
||||||
|
### A3. MPRIS is compiled into the Android build.
|
||||||
|
|
||||||
|
`mpris_linux.go` is `//go:build linux`, and **`android` implies
|
||||||
|
`linux`** (documented, and the reason it is in the APK). It will look
|
||||||
|
for a session bus that does not exist. It needs `//go:build linux &&
|
||||||
|
!android`, and its Android counterpart is A4.
|
||||||
|
|
||||||
|
This one is cheap and should be done regardless — it is a two-character
|
||||||
|
build-tag change plus whatever `mediacontrols.New` returns instead.
|
||||||
|
|
||||||
|
### A4. Playback will be killed the moment the screen locks.
|
||||||
|
|
||||||
|
The scaffold's `WailsForegroundService` is typed **`dataSync`**
|
||||||
|
(`foregroundServiceType="dataSync"`, `FOREGROUND_SERVICE_TYPE_DATA_SYNC`),
|
||||||
|
and the manifest requests `FOREGROUND_SERVICE_DATA_SYNC`. A music player
|
||||||
|
needs `mediaPlayback` and `FOREGROUND_SERVICE_MEDIA_PLAYBACK`, plus a
|
||||||
|
`MediaSession` for lock-screen and notification transport controls,
|
||||||
|
plus **audio focus** — pause on a phone call, duck for a notification,
|
||||||
|
pause on headphone unplug. None of that exists today. `oto` will happily
|
||||||
|
keep writing to a stream nobody can hear.
|
||||||
|
|
||||||
|
This is the difference between "an app that plays audio" and "a music
|
||||||
|
player", and it is Java-side work in the scaffold plus a Go-side bridge.
|
||||||
|
|
||||||
|
## B. It works, but wrongly
|
||||||
|
|
||||||
|
### B1. The x86_64 half of the APK cannot run on any Android.
|
||||||
|
|
||||||
|
Established in plan 015: `modernc.org/libc`'s `Xlstat64` issues a raw
|
||||||
|
`lstat` on linux/amd64, which Android's seccomp forbids, so the process
|
||||||
|
takes `SIGSYS` the first time it touches the database. arm64 is
|
||||||
|
structurally unaffected (no `lstat` syscall exists; it routes through
|
||||||
|
`fstatat`).
|
||||||
|
|
||||||
|
So ~31 MB of the artifact is dead weight on *every* Android device,
|
||||||
|
including x86 Chromebooks. Options: drop `x86_64` from `abiFilters`
|
||||||
|
(smaller APK, no emulator target — which does not work anyway), or
|
||||||
|
carry it against a future modernc fix. **Dropping it is the honest
|
||||||
|
default**; it is also the only item in this plan that is a five-minute
|
||||||
|
change.
|
||||||
|
|
||||||
|
### B2. The UI is a desktop shell.
|
||||||
|
|
||||||
|
`MinWidth`/`MinHeight` are 800×600 and were *measured* — below ~780 the
|
||||||
|
header subtitle wraps the title out of its bar. A phone is ~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 `<app-sidebar>`
|
||||||
|
inside the drawer means reusing its `data-testid`s too, and a second
|
||||||
|
copy standing by in the DOM broke 30 specs that had nothing to do with
|
||||||
|
the phone. The rule holds — a second list of destinations would be
|
||||||
|
worse — but a shared component must be rendered only when it is wanted,
|
||||||
|
and the guard belongs in a test that names the reason.
|
||||||
|
|
||||||
|
## What is worth doing regardless of that decision
|
||||||
|
|
||||||
|
Cheap, independently useful, and each unblocks measurement:
|
||||||
|
|
||||||
|
1. **Drop `x86_64` from `abiFilters`** (B1) — or keep it and document
|
||||||
|
why. Five minutes.
|
||||||
|
2. **`//go:build linux && !android` on `mpris_linux.go`** (A3), so the
|
||||||
|
Android build stops carrying a D-Bus client. Small.
|
||||||
|
3. **A device smoke run**, which needs someone's phone and the published
|
||||||
|
APK. Everything in D depends on it, and it is the single highest
|
||||||
|
information-per-minute action available.
|
||||||
|
4. **Make the first-run wizard fail legibly** rather than inertly (A2)
|
||||||
|
— the picker's error already routes through `describeError`, but the
|
||||||
|
wizard still blocks pointer events, so an Android user sees a dead
|
||||||
|
screen rather than a sentence. Even under option 3 this is the right
|
||||||
|
behaviour.
|
||||||
|
|
||||||
|
|
||||||
|
## What is left (updated after A4)
|
||||||
|
|
||||||
|
**A4 is done.** `backend/mediacontrols/android.go` is a `Handler`
|
||||||
|
beside the MPRIS one, and the Java half is
|
||||||
|
`WailsForegroundService.java`: a `MediaSession`, a `MediaStyle`
|
||||||
|
transport notification and audio focus. It needed no new JNI and no new
|
||||||
|
Gradle dependency — `application.Android.StartForegroundService(json)`
|
||||||
|
going out, `WailsBridge.emitEvent` → the application event bus coming
|
||||||
|
back, and the platform `android.media.session` API rather than
|
||||||
|
androidx.media, which minSdk 21 makes available anyway.
|
||||||
|
|
||||||
|
Four decisions in it are worth keeping:
|
||||||
|
|
||||||
|
- **Ducking is a player concept, not a volume change.**
|
||||||
|
`Player.SetDuck` re-applies the *user's* level with an attenuation
|
||||||
|
offset, so `getUserVolume` still reports what the user chose and
|
||||||
|
nothing is persisted or emitted. A duck that wrote through to the
|
||||||
|
volume would let one notification tone permanently turn the music
|
||||||
|
down.
|
||||||
|
- **The duck path is pre-Oreo only.** From API 26 the framework ducks
|
||||||
|
the app itself and sends no `CAN_DUCK` focus change, so asking to be
|
||||||
|
told instead (`setWillPauseWhenDucked`) would mean pausing for every
|
||||||
|
notification tone, and doing both would attenuate twice.
|
||||||
|
- **An unchanged payload is not an event here either.** Every push
|
||||||
|
crosses JNI and re-delivers an Intent, and the player pushes state on
|
||||||
|
several paths that can agree.
|
||||||
|
- **After the first start, updates use `startService`.** From Android
|
||||||
|
12 an app in the background may not *start* a foreground service, but
|
||||||
|
it may keep delivering intents to one it already has — which is every
|
||||||
|
track change with the screen off.
|
||||||
|
|
||||||
|
The contract with Java — the payload keys, the state words, the command
|
||||||
|
names — is in `androidpayload.go`, deliberately *without* the `android`
|
||||||
|
build tag, so `go test` exercises it on every platform. Everything left
|
||||||
|
in `android.go` is untested by construction: it compiles only under a
|
||||||
|
cross-compiler and runs only on a phone.
|
||||||
|
|
||||||
|
**B1 is done: x86_64 is dropped.** 27.1 MB → 15.9 MB, measured. Three
|
||||||
|
places had to agree — `abiFilters`, the Makefile's `android:package`
|
||||||
|
(or Go still compiles a library Gradle then discards) and the
|
||||||
|
`native-code: 'arm64-v8a'$` assertion in `android-apk.yml`, whose
|
||||||
|
anchor is what stops it also matching the fat APK's line. Adding the
|
||||||
|
ABI back, if modernc ever fixes `Xlstat64`, is those same three edits.
|
||||||
|
|
||||||
|
**B2, the desktop shell.** Scope decided (below); **phases 1, 2 and 3
|
||||||
|
are done.**
|
||||||
|
|
||||||
|
- *Phase 1, the shell.* Below 600px the sidebar column is gone,
|
||||||
|
`<bottom-nav>` is the primary navigation, and the shell fits 320px
|
||||||
|
exactly — measured, from 652px in a 360px viewport before.
|
||||||
|
- *Phase 2, the full-screen now-playing view.* Where phase 1's seek bar
|
||||||
|
and volume went. A detail view, so Back pops the nav stack; it
|
||||||
|
composes the real transport components rather than copying them; and
|
||||||
|
it hides the bottom bar while it is up, so it carries its own queue
|
||||||
|
button.
|
||||||
|
- *Phase 3, long-press.* `utils/long-press.ts`: one document-capture
|
||||||
|
listener, installed once from `index.ts`, which turns a 500 ms
|
||||||
|
stationary touch into a synthetic `contextmenu` at the touch point.
|
||||||
|
Every menu in the app opens from that event, so all six components
|
||||||
|
gained the gesture without one of them changing — which is the same
|
||||||
|
argument `ContextMenuController` rests on, one layer lower. The
|
||||||
|
details that are not obvious are in `NOTES.md` (2026-08-17); the one
|
||||||
|
worth repeating is that ours is told from the browser's own
|
||||||
|
long-press event by **identity**, not `isTrusted`, because a test
|
||||||
|
cannot dispatch a trusted event and that path would otherwise be the
|
||||||
|
only uncovered one.
|
||||||
|
|
||||||
|
What is left of B2 is the track list, whose resizable columns are a
|
||||||
|
pointer feature with no touch equivalent. Not started.
|
||||||
|
|
||||||
|
**B3/B4** are unchanged, and B3 is now *possible* where it was not:
|
||||||
|
with all-files access, `tagwriter` can write in place.
|
||||||
|
|
||||||
|
### What the first device run answered (2026-08-17)
|
||||||
|
|
||||||
|
A4 **works**: playback survives the screen locking, and the transport
|
||||||
|
notification appears with cover art — which also settles the service's
|
||||||
|
access to a `MANAGE_EXTERNAL_STORAGE` path, the permission grant and
|
||||||
|
the lock-screen session in one observation. Everything below in "what
|
||||||
|
none of section A answered" was written before this and is now answered
|
||||||
|
except the OEM permission-flow variance.
|
||||||
|
|
||||||
|
It also found two faults no browser tier can see, both fixed and both
|
||||||
|
awaiting the next APK for confirmation (`NOTES.md`, same date):
|
||||||
|
|
||||||
|
- **Back quit the app from any depth.** The scaffold asks
|
||||||
|
`webView.canGoBack()`; the frontend had never used `history`. A
|
||||||
|
navigation is a history entry now, and `navStack` is gone rather than
|
||||||
|
kept beside it.
|
||||||
|
- **The transport was under the gesture bar** — or so the version
|
||||||
|
number said. `applyWindowInsets()` in `MainActivity` is right and
|
||||||
|
stays, but the phone is **Android 14**, where the system still insets
|
||||||
|
the window: the fix is pre-emptive and the symptom has another cause.
|
||||||
|
Still open, along with icons that do not appear at all. The phone's
|
||||||
|
WebView is **Chrome 113**, which is the lead (no Popover API, no
|
||||||
|
relaxed CSS nesting), and `make android-inspect` / `android-eval` are
|
||||||
|
how it gets asked.
|
||||||
|
|
||||||
|
The standing item is unchanged in kind: **B3 (tag writing) and the
|
||||||
|
permission flow still need a device**, and so does confirming these two.
|
||||||
|
|
||||||
|
### What none of section A answered
|
||||||
|
|
||||||
|
Nothing here has been observed on a device. The permission flow in
|
||||||
|
particular is the kind of thing that behaves differently across OEM
|
||||||
|
builds — `ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION` is
|
||||||
|
implemented inconsistently, which is why there is a fallback to the
|
||||||
|
global list, and neither path has been exercised.
|
||||||
|
|
||||||
|
A4 adds its own list of things only a device can answer, and they are
|
||||||
|
the likely first failures: whether the notification appears at all
|
||||||
|
(POST_NOTIFICATIONS is requested from `startForegroundService`, so a
|
||||||
|
user who declines gets a service with an invisible notification),
|
||||||
|
whether audio focus arrives while `oto`/oboe holds the output, whether
|
||||||
|
the lock screen picks up the session, and whether cover art decoded
|
||||||
|
from a `MANAGE_EXTERNAL_STORAGE` path is readable by the service.
|
||||||
@@ -388,7 +388,26 @@ rather than renaming them.
|
|||||||
came about.
|
came about.
|
||||||
- `config` — TOML-based settings. Settings page uses HTMX + templ for server-rendered HTML fragments.
|
- `config` — TOML-based settings. Settings page uses HTMX + templ for server-rendered HTML fragments.
|
||||||
- `playlist` / `smartplaylist` — Playlist CRUD and rule-based smart playlists.
|
- `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).
|
- `system` — OS-specific paths (XDG on Linux, `%LOCALAPPDATA%` on Windows).
|
||||||
- `explore` — Catalog search and browse over `explore_index`. See below.
|
- `explore` — Catalog search and browse over `explore_index`. See below.
|
||||||
Its **shelves** (`shelves.go`) are the page Explore shows before
|
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
|
`first-run-wizard` and the startup chrome are eager for the ordinary
|
||||||
reason — they are the first paint.
|
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
|
**A primary view is cached, not unmounted.** `index.ts` keeps every
|
||||||
primary view in the DOM and toggles a `.view-hidden` class, because that
|
primary view in the DOM and toggles a `.view-hidden` class, because that
|
||||||
is what preserves `scrollTop` across navigation — so
|
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
|
moving focus without setting it leaves the highlight on whichever
|
||||||
item the mouse last touched.
|
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
|
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
|
and both playlist detail views — and gained a roving tab stop through
|
||||||
`utils/roving-rows.ts`. **`track-list` deliberately does not use it**:
|
`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
|
still permits *programmatic* scrolling, so a probe that sets
|
||||||
`scrollLeft` passes on the broken build; the spec uses a wheel gesture.
|
`scrollLeft` passes on the broken build; the spec uses a wheel gesture.
|
||||||
|
|
||||||
|
**Below 600px it reflows instead, and that is the phone.** The sideways
|
||||||
|
scroll above was the concession available while the shell had one
|
||||||
|
layout; plan 016 B2 gives it a second. Under 600px the grid drops its
|
||||||
|
sidebar column, `<bottom-nav>` takes over as the primary navigation,
|
||||||
|
the header's controls shrink or stand down, and the shell measures
|
||||||
|
exactly 320px in a 320px viewport — so `layout-overflow.spec.ts` now
|
||||||
|
asserts *nothing needs scrolling to*, which is what WCAG 1.4.10 wanted
|
||||||
|
all along. 600 rather than the sidebar's 900 because 900 is a laptop:
|
||||||
|
the answer there is a narrower sidebar, which is still a sidebar.
|
||||||
|
|
||||||
|
Three rules in it are load-bearing, and the second cost 30 specs.
|
||||||
|
|
||||||
|
**A grid item's implicit minimum is its content**, so one child that
|
||||||
|
insists on 580px makes the *body* 580px wide inside a 360px viewport
|
||||||
|
and `overflow-x: hidden` then hides a third of the app rather than
|
||||||
|
fitting it. Every box between the viewport and the content that must
|
||||||
|
shrink carries `min-width: 0`, and the things that cannot shrink say so
|
||||||
|
in their own stylesheet — `search-bar`'s 200px floor, `job-indicator`'s
|
||||||
|
label, `audio-player`'s seek bar and volume. A media query inside a
|
||||||
|
shadow root is answered by the viewport, so a component states what it
|
||||||
|
drops at phone width itself rather than the shell reaching in.
|
||||||
|
|
||||||
|
**A duplicated component duplicates its handles.** `bottom-nav`'s
|
||||||
|
"More" opens the *same* `<app-sidebar>` in a `wa-drawer` rather than
|
||||||
|
listing the destinations again — but rendering it unconditionally put a
|
||||||
|
second copy of every `data-testid="nav-*"` in the DOM, and 30 existing
|
||||||
|
specs failed with "strict mode violation: resolved to 2 elements" on a
|
||||||
|
desktop viewport where the element is not even visible. It renders only
|
||||||
|
while the drawer is open, and `bottom-nav.test.ts` asserts its absence
|
||||||
|
before that.
|
||||||
|
|
||||||
|
**The tab bar is four destinations and a way to the rest.** Three to
|
||||||
|
five is where touch targets stop being thumb-sized; eleven over 360px
|
||||||
|
is 32px each. Which four is plan 016's committed subset, and everything
|
||||||
|
else — Settings included, because a phone still needs it — is behind
|
||||||
|
"More".
|
||||||
|
|
||||||
|
**The phone section of `index.css` is last on purpose.** A media query
|
||||||
|
adds no specificity, so a `@media (max-width: 599px)` block placed
|
||||||
|
above the plain rules it overrides loses to them — which is how phase 1
|
||||||
|
shipped a header that kept its 2em gutters and 24px title on a 390px
|
||||||
|
phone with every declaration dead and nothing failing. The shell fitted
|
||||||
|
anyway, because the fitting is done by `min-width: 0` and by each
|
||||||
|
component's own media query, which live in their own stylesheets and
|
||||||
|
have no later rule to lose to. Cosmetic declarations are exactly what
|
||||||
|
no assertion sees; a screenshot found it.
|
||||||
|
|
||||||
|
**`<now-playing-view>` is where the seek bar and volume went.** It is a
|
||||||
|
*detail* view (`DETAIL_LOADERS`, so the nav stack carries the way out —
|
||||||
|
a tab you cannot leave by pressing again is not a tab), reached from a
|
||||||
|
phone-only button over the mini player's art, and it **composes the
|
||||||
|
real `<seek-bar>`, `<player-controls>` and `<volume-control>`** rather
|
||||||
|
than reimplementing them. While it is up, `index.css` hides the bottom
|
||||||
|
bar through `body:has(#main-content[data-active-view="now-playing"])` —
|
||||||
|
the active view is already published as an attribute, and a class
|
||||||
|
toggled from `index.ts` would be a second expression of the same fact.
|
||||||
|
The view therefore carries its own queue button, because that button
|
||||||
|
lives in the bar it hides.
|
||||||
|
|
||||||
**The playing row is a shape, not a hue.** `track-list` and
|
**The playing row is a shape, not a hue.** `track-list` and
|
||||||
`queue-panel` draw a `::before` triangle in each row's own left
|
`queue-panel` draw a `::before` triangle in each row's own left
|
||||||
padding, plus `aria-current` — before, both rows were a background tint
|
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
|
## CI
|
||||||
|
|
||||||
Four workflows in `.gitea/workflows/`. Three of them package and
|
Five workflows in `.gitea/workflows/`. Four of them package and
|
||||||
publish (`arch-package`, `homebrew-formula`, `index-artifact`); only
|
publish (`arch-package`, `homebrew-formula`, `index-artifact`,
|
||||||
`ci.yml` gates, and it is the one to look at when deciding whether a
|
`android-apk`); only `ci.yml` gates, and it is the one to look at when
|
||||||
push was healthy.
|
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:
|
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`.**
|
**`build/`'s platform metadata is generated from `build/config.yml`.**
|
||||||
`wails3 task common:update:build-assets` rewrites `Info.plist`, the
|
`wails3 task common:update:build-assets` rewrites `Info.plist`, the
|
||||||
`.desktop` template, `nfpm.yaml` and the Windows manifest from that
|
`.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,
|
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`)
|
nfpm's `homepage` and `license` say in place that the refresh does not
|
||||||
say so in place. That refresh also regenerates `build/ios/` and
|
own them, and **that comment is wrong**: a refresh reset them to
|
||||||
`build/android/`, which this repo does not carry: they are gitignored
|
`https://wails.io` and `MIT`. Re-check those two after any refresh.
|
||||||
rather than deleted-and-rediscovered, and their `includes:` entries
|
|
||||||
are dropped from `Taskfile.yml`. `build/config.yml`'s `version` is the
|
**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
|
*metadata* version and is not what the app reports — `main.version` is
|
||||||
stamped at link time from the packaging recipe's git-derived version.
|
stamped at link time from the packaging recipe's git-derived version.
|
||||||
|
|||||||
@@ -38,6 +38,64 @@ dev-stop: ## Stop the headless app (SIGTERM, so shutdown hooks run)
|
|||||||
dev-logs: ## Tail the headless app log
|
dev-logs: ## Tail the headless app log
|
||||||
@tail -f .dev/app.log
|
@tail -f .dev/app.log
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- #
|
||||||
|
# The Android tier. See .pi/skills/yellowjacket-dev/references/ #
|
||||||
|
# android-tier.md for which of these to reach for and why a failure #
|
||||||
|
# here looks like nothing at all. #
|
||||||
|
# ---------------------------------------------------------------- #
|
||||||
|
|
||||||
|
# The NDK is pinned: r26d is what the pipeline is built and checked
|
||||||
|
# against, and newer NDKs have broken Wails' Android build before.
|
||||||
|
# ANDROID_HOME must carry a *platform*, which Arch's /opt/android-sdk
|
||||||
|
# does not — hence the separate default.
|
||||||
|
ANDROID_SDK ?= $(HOME)/Android/Sdk
|
||||||
|
ANDROID_NDK ?= /opt/android-ndk
|
||||||
|
ANDROID_ENV := ANDROID_HOME=$(ANDROID_SDK) ANDROID_SDK_ROOT=$(ANDROID_SDK) ANDROID_NDK_HOME=$(ANDROID_NDK)
|
||||||
|
|
||||||
|
# `package`, not `package:fat`: x86_64 Android cannot run this app at
|
||||||
|
# all (modernc's raw lstat vs Android's seccomp -- see
|
||||||
|
# android-tier.md), so the second ABI was ~31 MB that could not run
|
||||||
|
# anywhere. app/build.gradle's abiFilters says the same thing to
|
||||||
|
# Gradle; both have to agree or the .so is built and then dropped.
|
||||||
|
android: build-frontend ## Build the arm64 APK into bin/
|
||||||
|
@$(ANDROID_ENV) PATH="$(TOOLBIN):$$PATH" go tool wails3 task android:package
|
||||||
|
|
||||||
|
android-setup: ## Install the SDK pieces and create the AVD (once, ~3.5GB)
|
||||||
|
@$(ANDROID_ENV) ./scripts/android-emulator.sh setup
|
||||||
|
|
||||||
|
android-emulator: ## Boot the emulator headless in the background and wait for it
|
||||||
|
@$(ANDROID_ENV) ./scripts/android-emulator.sh start
|
||||||
|
|
||||||
|
android-emulator-stop: ## Shut the emulator down (console kill, then saved PID)
|
||||||
|
@$(ANDROID_ENV) ./scripts/android-emulator.sh stop
|
||||||
|
|
||||||
|
android-install: ## Install bin/yellowjacket.apk onto the running emulator
|
||||||
|
@$(ANDROID_ENV) ./scripts/android-emulator.sh install
|
||||||
|
|
||||||
|
android-launch: ## Force-stop, clear logcat, and start the app
|
||||||
|
@$(ANDROID_ENV) ./scripts/android-emulator.sh launch
|
||||||
|
|
||||||
|
android-logs: ## Tail logcat, filtered to the app's own tags
|
||||||
|
@$(ANDROID_ENV) ./scripts/android-emulator.sh logs
|
||||||
|
|
||||||
|
# The only tier that can see the platform is the one you can look at.
|
||||||
|
android-screenshot: ## Grab the device screen (OUT=<path>)
|
||||||
|
@$(ANDROID_ENV) ./scripts/android-emulator.sh screenshot $(OUT)
|
||||||
|
|
||||||
|
# The page's own answer, from the engine that is really rendering it.
|
||||||
|
# Needs the debug build installed (it is a sibling id, so it does not
|
||||||
|
# disturb the release app): see scripts/android-eval.mjs.
|
||||||
|
android-inspect: ## Forward the device WebView's devtools socket
|
||||||
|
@$(ANDROID_ENV) ./scripts/android-emulator.sh inspect
|
||||||
|
|
||||||
|
android-eval: ## Evaluate JS in the device WebView (EXPR='...')
|
||||||
|
@node ./scripts/android-eval.mjs $(if $(EXPR),'$(EXPR)',)
|
||||||
|
|
||||||
|
# "Did it start" is the wrong question — a crash-looping app starts
|
||||||
|
# several times a second. This asserts the *same pid* is still there.
|
||||||
|
android-smoke: ## Launch and assert the app is still alive (SECONDS=<n>)
|
||||||
|
@$(ANDROID_ENV) ./scripts/android-emulator.sh smoke $(if $(SECONDS),$(SECONDS),10)
|
||||||
|
|
||||||
# Seeds are produced by *running the app* — driving the real AddLibrary
|
# Seeds are produced by *running the app* — driving the real AddLibrary
|
||||||
# binding and waiting for the real scan — never by hand-writing a
|
# 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
|
# config.toml and DB rows. A hand-built seed is a second description
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ includes:
|
|||||||
windows: ./build/windows/Taskfile.yml
|
windows: ./build/windows/Taskfile.yml
|
||||||
darwin: ./build/darwin/Taskfile.yml
|
darwin: ./build/darwin/Taskfile.yml
|
||||||
linux: ./build/linux/Taskfile.yml
|
linux: ./build/linux/Taskfile.yml
|
||||||
|
android: ./build/android/Taskfile.yml
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -484,20 +484,24 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
|||||||
// Register playback finished handler to drive queue auto-advance.
|
// Register playback finished handler to drive queue auto-advance.
|
||||||
yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished)
|
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)
|
yj.mediaControls = mediacontrols.NewHandler(yj.logger)
|
||||||
|
|
||||||
if err := yj.mediaControls.Init(mediacontrols.Callbacks{
|
if err := yj.mediaControls.Init(mediacontrols.Callbacks{
|
||||||
OnPlay: yj.queue.Play,
|
OnPlay: yj.queue.Play,
|
||||||
OnPause: func() {
|
OnPause: func() {
|
||||||
if err := yj.player.Pause(); err != nil {
|
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() {
|
OnPlayPause: func() {
|
||||||
if yj.player.IsPlaying() {
|
if yj.player.IsPlaying() {
|
||||||
if err := yj.player.Pause(); err != nil {
|
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 {
|
} else {
|
||||||
yj.queue.Play()
|
yj.queue.Play()
|
||||||
@@ -505,14 +509,14 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
|||||||
},
|
},
|
||||||
OnStop: func() {
|
OnStop: func() {
|
||||||
if err := yj.player.Pause(); err != nil {
|
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,
|
OnNext: yj.queue.Next,
|
||||||
OnPrevious: yj.queue.Previous,
|
OnPrevious: yj.queue.Previous,
|
||||||
OnSeek: func(positionSec int) {
|
OnSeek: func(positionSec int) {
|
||||||
if err := yj.player.Seek(positionSec); err != nil {
|
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) {
|
OnVolume: func(vol float64) {
|
||||||
@@ -522,6 +526,7 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
OnDuck: yj.player.SetDuck,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
yj.logger.Error(
|
yj.logger.Error(
|
||||||
"Failed to initialize media controls",
|
"Failed to initialize media controls",
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
//go:build android
|
||||||
|
|
||||||
|
// Android's answer to MPRIS is a MediaSession, and reaching it needs no
|
||||||
|
// new JNI: Wails exports application.Android.StartForegroundService(json)
|
||||||
|
// going out, and Java's WailsBridge.emitEvent lands on the application
|
||||||
|
// event bus coming back. So this handler is one JSON payload pushed to
|
||||||
|
// the foreground service and one command event read from it. The Java
|
||||||
|
// half is
|
||||||
|
// build/android/app/src/main/java/com/wails/app/WailsForegroundService.java
|
||||||
|
// and the payload keys below are its contract.
|
||||||
|
|
||||||
|
package mediacontrols
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
// commandEvent is the event name the Java side emits transport
|
||||||
|
// commands on. It is a plain string on both sides; changing it means
|
||||||
|
// changing WailsForegroundService too.
|
||||||
|
const commandEvent = "yj:media:command"
|
||||||
|
|
||||||
|
var errNoApplication = errors.New(
|
||||||
|
"no running application to attach media controls to",
|
||||||
|
)
|
||||||
|
|
||||||
|
// androidHandler drives the media notification, the lock-screen
|
||||||
|
// transport and audio focus through the foreground service.
|
||||||
|
type androidHandler struct {
|
||||||
|
logger *slog.Logger
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
callbacks Callbacks
|
||||||
|
meta Metadata
|
||||||
|
state PlaybackState
|
||||||
|
positionSec int
|
||||||
|
|
||||||
|
// running tracks whether the foreground service has been started.
|
||||||
|
// Android 12+ forbids starting one from the background, so it is
|
||||||
|
// started when playback starts -- a user action, in a visible app
|
||||||
|
// -- and stopped only when playback stops, which is what keeps
|
||||||
|
// queue auto-advance working with the screen off.
|
||||||
|
running bool
|
||||||
|
|
||||||
|
// lastPayload is the last JSON sent. An unchanged payload is not
|
||||||
|
// an event here either: every push crosses JNI and re-delivers an
|
||||||
|
// Intent, and the player pushes state on several paths that can
|
||||||
|
// agree.
|
||||||
|
lastPayload string
|
||||||
|
|
||||||
|
unsubscribe func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler returns the Android media-session handler.
|
||||||
|
func NewHandler(logger *slog.Logger) Handler {
|
||||||
|
return &androidHandler{logger: logger, state: StateStopped}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init subscribes to the transport commands the Java side emits.
|
||||||
|
func (a *androidHandler) Init(callbacks Callbacks) error {
|
||||||
|
app := application.Get()
|
||||||
|
if app == nil {
|
||||||
|
return errNoApplication
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
a.callbacks = callbacks
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
a.unsubscribe = app.Event.On(commandEvent, a.onCommand)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// onCommand dispatches one transport command from the notification,
|
||||||
|
// the lock screen, a headset button or an audio-focus change.
|
||||||
|
//
|
||||||
|
// Every callback runs on its own goroutine, for the reason the MPRIS
|
||||||
|
// handler does the same: they take the player and queue mutexes, and
|
||||||
|
// this runs on the event processor's dispatch goroutine.
|
||||||
|
func (a *androidHandler) onCommand(event *application.CustomEvent) {
|
||||||
|
data, ok := event.Data.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
command := parseMediaCommand(data)
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
cb := a.callbacks
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
switch command.name {
|
||||||
|
case cmdPlay:
|
||||||
|
run(cb.OnPlay)
|
||||||
|
case cmdPause:
|
||||||
|
run(cb.OnPause)
|
||||||
|
case cmdPlayPause:
|
||||||
|
run(cb.OnPlayPause)
|
||||||
|
case cmdStop:
|
||||||
|
run(cb.OnStop)
|
||||||
|
case cmdNext:
|
||||||
|
run(cb.OnNext)
|
||||||
|
case cmdPrevious:
|
||||||
|
run(cb.OnPrevious)
|
||||||
|
case cmdSeek:
|
||||||
|
if cb.OnSeek != nil {
|
||||||
|
go cb.OnSeek(command.positionSec)
|
||||||
|
}
|
||||||
|
case cmdDuck:
|
||||||
|
if cb.OnDuck != nil {
|
||||||
|
go cb.OnDuck(command.duck)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
a.logger.Warn("Unknown media command", "command", command.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run invokes a callback on its own goroutine, tolerating a nil one.
|
||||||
|
func run(fn func()) {
|
||||||
|
if fn != nil {
|
||||||
|
go fn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMetadata pushes new track details to the notification.
|
||||||
|
func (a *androidHandler) UpdateMetadata(meta Metadata) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
a.meta = meta
|
||||||
|
a.push()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePlaybackState pushes the state and a fresh position anchor;
|
||||||
|
// the MediaSession interpolates from there while playing.
|
||||||
|
func (a *androidHandler) UpdatePlaybackState(
|
||||||
|
state PlaybackState,
|
||||||
|
positionSec int,
|
||||||
|
) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
a.state = state
|
||||||
|
a.positionSec = positionSec
|
||||||
|
a.push()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifySeek re-anchors the position. Unlike MPRIS, a MediaSession has
|
||||||
|
// no separate seeked signal -- a new state with a new position is the
|
||||||
|
// whole mechanism.
|
||||||
|
func (a *androidHandler) NotifySeek(positionSec int) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
a.positionSec = positionSec
|
||||||
|
a.push()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateVolume is deliberately a no-op. Android's volume keys act on
|
||||||
|
// the media stream, which the OS owns; an app that also moved its own
|
||||||
|
// volume in response would move it twice.
|
||||||
|
func (a *androidHandler) UpdateVolume(_ float64) {}
|
||||||
|
|
||||||
|
// Close stops the service and drops the command subscription.
|
||||||
|
func (a *androidHandler) Close() {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
if a.unsubscribe != nil {
|
||||||
|
a.unsubscribe()
|
||||||
|
a.unsubscribe = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.running {
|
||||||
|
application.Android.StopForegroundService()
|
||||||
|
a.running = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// push sends the current state to the Java side, if it has changed.
|
||||||
|
// The caller holds a.mu.
|
||||||
|
func (a *androidHandler) push() {
|
||||||
|
if a.state == StateStopped {
|
||||||
|
// Nothing is playing, so nothing justifies an ongoing
|
||||||
|
// notification or the process staying alive.
|
||||||
|
if a.running {
|
||||||
|
application.Android.StopForegroundService()
|
||||||
|
a.running = false
|
||||||
|
a.lastPayload = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := mediaPayload(a.meta, a.state, a.positionSec)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Error("Failed to encode media payload", "err", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload == a.lastPayload {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.lastPayload = payload
|
||||||
|
a.running = true
|
||||||
|
|
||||||
|
application.Android.StartForegroundService(payload)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// The contract between the Android handler and the Java
|
||||||
|
// WailsForegroundService is two JSON documents -- one pushed out with
|
||||||
|
// the track and the state, one read back with a transport command --
|
||||||
|
// and neither side can check the other.
|
||||||
|
//
|
||||||
|
// It lives here, *without* the android build tag, so that `go test` on
|
||||||
|
// any platform exercises it. android.go itself can only be compiled by
|
||||||
|
// a cross-compiler and only be run by a phone, so anything left in it
|
||||||
|
// is untested by construction; this is the half worth not leaving
|
||||||
|
// there.
|
||||||
|
|
||||||
|
package mediacontrols
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// Media command names, as the Java side spells them.
|
||||||
|
const (
|
||||||
|
cmdPlay = "play"
|
||||||
|
cmdPause = "pause"
|
||||||
|
cmdPlayPause = "playpause"
|
||||||
|
cmdStop = "stop"
|
||||||
|
cmdNext = "next"
|
||||||
|
cmdPrevious = "previous"
|
||||||
|
cmdSeek = "seek"
|
||||||
|
cmdDuck = "duck"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stateNames are what the payload's "state" key carries. Words rather
|
||||||
|
// than the PlaybackState integers, because the Java side reads them as
|
||||||
|
// JSON and a renumbered constant would silently mean something else
|
||||||
|
// there.
|
||||||
|
var stateNames = map[PlaybackState]string{
|
||||||
|
StateStopped: "stopped",
|
||||||
|
StatePlaying: "playing",
|
||||||
|
StatePaused: "paused",
|
||||||
|
}
|
||||||
|
|
||||||
|
// mediaCommand is one transport command from the notification, the
|
||||||
|
// lock screen, a headset button or an audio-focus change.
|
||||||
|
type mediaCommand struct {
|
||||||
|
name string
|
||||||
|
positionSec int
|
||||||
|
duck bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// mediaPayload encodes the state the notification and MediaSession
|
||||||
|
// render.
|
||||||
|
func mediaPayload(
|
||||||
|
meta Metadata,
|
||||||
|
state PlaybackState,
|
||||||
|
positionSec int,
|
||||||
|
) (string, error) {
|
||||||
|
payload, err := json.Marshal(map[string]any{
|
||||||
|
"title": meta.Title,
|
||||||
|
"artist": meta.Artist,
|
||||||
|
"album": meta.Album,
|
||||||
|
"artPath": meta.ArtFilePath,
|
||||||
|
"durationSec": meta.DurationSec,
|
||||||
|
"positionSec": positionSec,
|
||||||
|
"state": stateNames[state],
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(payload), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMediaCommand reads one command out of the event payload.
|
||||||
|
//
|
||||||
|
// The numbers arrive as float64 because they came through
|
||||||
|
// encoding/json as an untyped document -- asserting int here is the
|
||||||
|
// way a seek silently becomes a seek to zero.
|
||||||
|
func parseMediaCommand(data map[string]any) mediaCommand {
|
||||||
|
cmd := mediaCommand{}
|
||||||
|
cmd.name, _ = data["command"].(string)
|
||||||
|
|
||||||
|
if position, ok := data["positionSec"].(float64); ok {
|
||||||
|
cmd.positionSec = int(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.duck, _ = data["on"].(bool)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package mediacontrols
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMediaPayloadKeys pins the document the Java side parses. The
|
||||||
|
// keys are the contract: a rename here is silently a track with no
|
||||||
|
// title on the lock screen, because WailsForegroundService reads them
|
||||||
|
// with optString and a missing key is simply "".
|
||||||
|
func TestMediaPayloadKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
payload, err := mediaPayload(Metadata{
|
||||||
|
Title: "Tideline",
|
||||||
|
Artist: "Sea Change",
|
||||||
|
Album: "Ebb",
|
||||||
|
ArtFilePath: "/covers/ebb_lg.jpg",
|
||||||
|
DurationSec: 245,
|
||||||
|
}, StatePlaying, 30)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mediaPayload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(payload), &got); err != nil {
|
||||||
|
t.Fatalf("payload is not JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := map[string]any{
|
||||||
|
"title": "Tideline",
|
||||||
|
"artist": "Sea Change",
|
||||||
|
"album": "Ebb",
|
||||||
|
"artPath": "/covers/ebb_lg.jpg",
|
||||||
|
"durationSec": float64(245),
|
||||||
|
"positionSec": float64(30),
|
||||||
|
"state": "playing",
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Errorf("payload has %d keys, want %d: %s", len(got), len(want), payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, expected := range want {
|
||||||
|
if got[key] != expected {
|
||||||
|
t.Errorf("payload[%q] = %v, want %v", key, got[key], expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMediaPayloadStateNames covers the one value the Java side
|
||||||
|
// compares against a literal.
|
||||||
|
func TestMediaPayloadStateNames(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
state PlaybackState
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{StatePlaying, "playing"},
|
||||||
|
{StatePaused, "paused"},
|
||||||
|
{StateStopped, "stopped"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
payload, err := mediaPayload(Metadata{}, tt.state, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mediaPayload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(payload), &got); err != nil {
|
||||||
|
t.Fatalf("payload is not JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.State != tt.want {
|
||||||
|
t.Errorf("state %d encoded as %q, want %q", tt.state, got.State, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseMediaCommand covers the direction that arrives untyped.
|
||||||
|
// The seek case is the one with teeth: the position crosses as a JSON
|
||||||
|
// number, so it is a float64 in the map and an int assertion would
|
||||||
|
// make every seek a seek to zero.
|
||||||
|
func TestParseMediaCommand(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
data map[string]any
|
||||||
|
want mediaCommand
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "play",
|
||||||
|
data: map[string]any{"command": "play"},
|
||||||
|
want: mediaCommand{name: cmdPlay},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "seek carries a position",
|
||||||
|
data: map[string]any{"command": "seek", "positionSec": float64(93)},
|
||||||
|
want: mediaCommand{name: cmdSeek, positionSec: 93},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duck carries a flag",
|
||||||
|
data: map[string]any{"command": "duck", "on": true},
|
||||||
|
want: mediaCommand{name: cmdDuck, duck: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unduck",
|
||||||
|
data: map[string]any{"command": "duck", "on": false},
|
||||||
|
want: mediaCommand{name: cmdDuck},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a command with nothing in it is not a panic",
|
||||||
|
data: map[string]any{},
|
||||||
|
want: mediaCommand{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrongly typed fields fall back to zero",
|
||||||
|
data: map[string]any{"command": "seek", "positionSec": "93"},
|
||||||
|
want: mediaCommand{name: cmdSeek},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if got := parseMediaCommand(tt.data); got != tt.want {
|
||||||
|
t.Errorf("parseMediaCommand(%v) = %+v, want %+v", tt.data, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMediaCommandNamesAreWhatJavaSends is a spelling check against
|
||||||
|
// the Java side, which builds these strings by hand. It is a list, not
|
||||||
|
// a mechanism: nothing can reach across into the .java file, so the
|
||||||
|
// point is that changing one of these constants fails a test that
|
||||||
|
// names the file to change with it.
|
||||||
|
//
|
||||||
|
// See build/android/app/src/main/java/com/wails/app/WailsForegroundService.java.
|
||||||
|
func TestMediaCommandNamesAreWhatJavaSends(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
want := []string{
|
||||||
|
"play", "pause", "playpause", "stop",
|
||||||
|
"next", "previous", "seek", "duck",
|
||||||
|
}
|
||||||
|
got := []string{
|
||||||
|
cmdPlay, cmdPause, cmdPlayPause, cmdStop,
|
||||||
|
cmdNext, cmdPrevious, cmdSeek, cmdDuck,
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, name := range want {
|
||||||
|
if got[i] != name {
|
||||||
|
t.Errorf("command %d = %q, want %q", i, got[i], name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,13 @@ type Callbacks struct {
|
|||||||
OnPrevious func()
|
OnPrevious func()
|
||||||
OnSeek func(positionSec int)
|
OnSeek func(positionSec int)
|
||||||
OnVolume func(volume float64) // 0.0–1.0 linear scale.
|
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.
|
// Handler manages the OS media control integration.
|
||||||
|
|||||||
@@ -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
|
package mediacontrols
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
//go:build !linux
|
//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
|
package mediacontrols
|
||||||
|
|
||||||
import "log/slog"
|
import "log/slog"
|
||||||
|
|||||||
@@ -56,6 +56,13 @@ type Player struct {
|
|||||||
trackChangeID uint64
|
trackChangeID uint64
|
||||||
mediaControls mediacontrols.Handler
|
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
|
// trackLengthMs holds the authoritative track duration in
|
||||||
// milliseconds, sourced from the database (which uses the
|
// milliseconds, sourced from the database (which uses the
|
||||||
// custom header parser). The go-mp3 decoder's Len() can be
|
// custom header parser). The go-mp3 decoder's Len() can be
|
||||||
@@ -793,12 +800,40 @@ func (p *Player) setVolumeLocked(desiredVolume UserVolume) {
|
|||||||
speaker.Lock()
|
speaker.Lock()
|
||||||
|
|
||||||
volume := clampVolume(desiredVolume)
|
volume := clampVolume(desiredVolume)
|
||||||
p.volume.Volume = float64(volume.ToVolume())
|
p.volume.Volume = float64(volume.ToVolume()) - p.duckAmount
|
||||||
p.volume.Silent = volume == MinUserVol
|
p.volume.Silent = volume == MinUserVol
|
||||||
|
|
||||||
speaker.Unlock()
|
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.
|
// ChangeVolume adjusts the volume by a relative amount.
|
||||||
func (p *Player) ChangeVolume(deltaVolume int) error {
|
func (p *Player) ChangeVolume(deltaVolume int) error {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
@@ -812,7 +847,9 @@ func (p *Player) ChangeVolume(deltaVolume int) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Player) getUserVolume() UserVolume {
|
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.
|
// Muted reports whether playback is currently silenced.
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ const (
|
|||||||
MaxVol Volume = 0
|
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.
|
// ToVolume converts user volume to internal player volume.
|
||||||
func (oldVol UserVolume) ToVolume() Volume {
|
func (oldVol UserVolume) ToVolume() Volume {
|
||||||
var newVol Volume
|
var newVol Volume
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package player
|
package player
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gopxl/beep/v2/effects"
|
||||||
|
|
||||||
"yellowjacket/backend/mediacontrols"
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,29 @@ const (
|
|||||||
// without touching the current user's real config.toml or yj.db.
|
// without touching the current user's real config.toml or yj.db.
|
||||||
const envHomeOverride = "YJ_HOME"
|
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.
|
// getUserDirPath returns and creates the path for a user directory.
|
||||||
func getUserDirPath(dt dirType) (string, error) {
|
func getUserDirPath(dt dirType) (string, error) {
|
||||||
path, err := resolveUserDirPath(dt)
|
path, err := resolveUserDirPath(dt)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package system
|
package system
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,486 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
includes:
|
||||||
|
common: ../Taskfile.yml
|
||||||
|
|
||||||
|
vars:
|
||||||
|
# The *installed* package name, which every adb-driven task below uses
|
||||||
|
# to uninstall, launch and filter. It must agree with `applicationId`
|
||||||
|
# in app/build.gradle, and nothing enforces that.
|
||||||
|
#
|
||||||
|
# ANDROID.md says to set this in build/config.yml. That does not work
|
||||||
|
# in beta.8, checked both ways: `wails3 task` builds its var set from
|
||||||
|
# CLI KEY=VALUE arguments and the Taskfile tree only -- nothing reads
|
||||||
|
# config.yml -- and even when set it feeds only these adb commands,
|
||||||
|
# never Gradle. So the identity is declared twice, here and in
|
||||||
|
# build.gradle, and a change to one alone means the official run and
|
||||||
|
# deploy tasks address a package that is not installed.
|
||||||
|
APP_ID: '{{.APP_ID | default "app.yellowjacket"}}'
|
||||||
|
MIN_SDK: '21'
|
||||||
|
TARGET_SDK: '35'
|
||||||
|
# The emulator runs the host architecture; physical devices are arm64
|
||||||
|
HOST_ARCH:
|
||||||
|
sh: '[ "$(uname -m)" = "x86_64" ] && echo "amd64" || echo "arm64"'
|
||||||
|
# System-image ABI for the host, used in the "create an AVD" hint below.
|
||||||
|
ANDROID_ABI:
|
||||||
|
sh: '[ "$(uname -m)" = "arm64" ] && echo "arm64-v8a" || echo "x86_64"'
|
||||||
|
# SDK location: $ANDROID_HOME / $ANDROID_SDK_ROOT, else the per-OS default
|
||||||
|
# (macOS: ~/Library/Android/sdk, Linux/other: ~/Android/Sdk)
|
||||||
|
SDK_ROOT:
|
||||||
|
sh: 'echo "${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$([ -d "$HOME/Library/Android/sdk" ] && echo "$HOME/Library/Android/sdk" || echo "$HOME/Android/Sdk")}}"'
|
||||||
|
ADB:
|
||||||
|
sh: 'command -v adb || echo "${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$([ -d "$HOME/Library/Android/sdk" ] && echo "$HOME/Library/Android/sdk" || echo "$HOME/Android/Sdk")}}/platform-tools/adb"'
|
||||||
|
EMULATOR:
|
||||||
|
sh: 'command -v emulator || echo "${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$([ -d "$HOME/Library/Android/sdk" ] && echo "$HOME/Library/Android/sdk" || echo "$HOME/Android/Sdk")}}/emulator/emulator"'
|
||||||
|
# avdmanager lives under cmdline-tools/<version>/bin; used to auto-create an
|
||||||
|
# AVD when none exists (mirrors the iOS `ensure-simulator` auto-create flow).
|
||||||
|
AVDMANAGER:
|
||||||
|
sh: 'command -v avdmanager || ls "${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$([ -d "$HOME/Library/Android/sdk" ] && echo "$HOME/Library/Android/sdk" || echo "$HOME/Android/Sdk")}}"/cmdline-tools/*/bin/avdmanager 2>/dev/null | sort -V | tail -1 || true'
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
install:deps:
|
||||||
|
summary: Check and install Android development dependencies
|
||||||
|
cmds:
|
||||||
|
- go run build/android/scripts/deps/install_deps.go
|
||||||
|
env:
|
||||||
|
TASK_FORCE_YES: '{{if .YES}}true{{else}}false{{end}}'
|
||||||
|
prompt: This will check and install Android development dependencies. Continue?
|
||||||
|
|
||||||
|
build:
|
||||||
|
summary: Creates a debug build of the application for Android
|
||||||
|
deps:
|
||||||
|
- task: common:go:mod:tidy
|
||||||
|
- task: generate:android:overlay
|
||||||
|
- task: common:build:frontend
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS:
|
||||||
|
ref: .BUILD_FLAGS
|
||||||
|
PRODUCTION:
|
||||||
|
ref: .PRODUCTION
|
||||||
|
cmds:
|
||||||
|
- echo "Building Android app {{.APP_NAME}}..."
|
||||||
|
- task: compile:go:shared
|
||||||
|
vars:
|
||||||
|
ARCH: '{{.ARCH | default .HOST_ARCH}}'
|
||||||
|
# This repo's one edit to the android scaffold, and it is not
|
||||||
|
# cosmetic. Upstream forwards ARCH here and not PRODUCTION, so
|
||||||
|
# compile:go:shared recomputed BUILD_FLAGS against an unset
|
||||||
|
# .PRODUCTION and fell back to the debug branch. package:fat
|
||||||
|
# calls compile:go:shared directly for amd64 (passing it), and
|
||||||
|
# reaches arm64 only through this task -- so a release APK
|
||||||
|
# shipped a *debug* 40 MB arm64 library beside a production
|
||||||
|
# 31 MB x86_64 one. The phone ABI, which is the only one a
|
||||||
|
# release is for, was the broken one. 34 MB APK before, 27
|
||||||
|
# after.
|
||||||
|
PRODUCTION: '{{.PRODUCTION}}'
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production,android -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-tags android,debug -buildvcs=false -gcflags=all="-l"{{end}}'
|
||||||
|
env:
|
||||||
|
PRODUCTION: '{{.PRODUCTION | default "false"}}'
|
||||||
|
|
||||||
|
compile:go:shared:
|
||||||
|
summary: Compile Go code to shared library (.so)
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
# Locate the NDK: $ANDROID_NDK_HOME, or the newest installed NDK
|
||||||
|
NDK_ROOT="$ANDROID_NDK_HOME"
|
||||||
|
if [ -z "$NDK_ROOT" ]; then
|
||||||
|
SDK_ROOT="{{.SDK_ROOT}}"
|
||||||
|
NDK_ROOT=$(ls -d "$SDK_ROOT"/ndk/* 2>/dev/null | sort -V | tail -1)
|
||||||
|
fi
|
||||||
|
if [ -z "$NDK_ROOT" ] || [ ! -d "$NDK_ROOT" ]; then
|
||||||
|
echo "Error: Android NDK not found"
|
||||||
|
echo "Install one with: sdkmanager 'ndk;26.3.11579264' (or set ANDROID_NDK_HOME)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Determine toolchain based on host OS
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Darwin) HOST_TAG="darwin-x86_64" ;;
|
||||||
|
Linux) HOST_TAG="linux-x86_64" ;;
|
||||||
|
*) echo "Unsupported host OS"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
TOOLCHAIN="$NDK_ROOT/toolchains/llvm/prebuilt/$HOST_TAG"
|
||||||
|
|
||||||
|
# Set compiler based on architecture
|
||||||
|
case "{{.ARCH}}" in
|
||||||
|
arm64)
|
||||||
|
export CC="$TOOLCHAIN/bin/aarch64-linux-android{{.MIN_SDK}}-clang"
|
||||||
|
export CXX="$TOOLCHAIN/bin/aarch64-linux-android{{.MIN_SDK}}-clang++"
|
||||||
|
export GOARCH=arm64
|
||||||
|
JNI_DIR="arm64-v8a"
|
||||||
|
;;
|
||||||
|
amd64|x86_64)
|
||||||
|
export CC="$TOOLCHAIN/bin/x86_64-linux-android{{.MIN_SDK}}-clang"
|
||||||
|
export CXX="$TOOLCHAIN/bin/x86_64-linux-android{{.MIN_SDK}}-clang++"
|
||||||
|
export GOARCH=amd64
|
||||||
|
JNI_DIR="x86_64"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unsupported architecture: {{.ARCH}}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
export CGO_ENABLED=1
|
||||||
|
export GOOS=android
|
||||||
|
|
||||||
|
mkdir -p {{.BIN_DIR}}
|
||||||
|
mkdir -p build/android/app/src/main/jniLibs/$JNI_DIR
|
||||||
|
|
||||||
|
go build -buildmode=c-shared -overlay build/android/overlay.json {{.BUILD_FLAGS}} \
|
||||||
|
-o build/android/app/src/main/jniLibs/$JNI_DIR/libwails.so
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production,android -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-tags android,debug -buildvcs=false -gcflags=all="-l"{{end}}'
|
||||||
|
|
||||||
|
compile:go:all-archs:
|
||||||
|
summary: Compile Go code for all Android architectures (fat APK)
|
||||||
|
cmds:
|
||||||
|
- task: compile:go:shared
|
||||||
|
vars:
|
||||||
|
ARCH: arm64
|
||||||
|
- task: compile:go:shared
|
||||||
|
vars:
|
||||||
|
ARCH: amd64
|
||||||
|
|
||||||
|
package:
|
||||||
|
summary: Packages a production build of the application into a signed release APK
|
||||||
|
desc: |
|
||||||
|
Builds for arm64 by default (covers 99%+ of real devices). Set ARCH=amd64
|
||||||
|
for emulator-only APKs, or use package:fat for a universal APK.
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
vars:
|
||||||
|
PRODUCTION: "true"
|
||||||
|
ARCH: '{{.ARCH | default "arm64"}}'
|
||||||
|
cmds:
|
||||||
|
- task: assemble:apk:release
|
||||||
|
|
||||||
|
package:fat:
|
||||||
|
summary: Packages a production build for all architectures (fat APK)
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
vars:
|
||||||
|
PRODUCTION: "true"
|
||||||
|
ARCH: arm64
|
||||||
|
cmds:
|
||||||
|
- task: compile:go:shared
|
||||||
|
vars:
|
||||||
|
ARCH: amd64
|
||||||
|
PRODUCTION: "true"
|
||||||
|
- task: assemble:apk:release
|
||||||
|
|
||||||
|
bundle:
|
||||||
|
summary: Packages a production AAB (Android App Bundle) for Play Store submission
|
||||||
|
desc: |
|
||||||
|
Builds for arm64 by default. Set ARCH=amd64 for emulator builds, or use
|
||||||
|
bundle:fat for a universal AAB.
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
vars:
|
||||||
|
PRODUCTION: "true"
|
||||||
|
ARCH: '{{.ARCH | default "arm64"}}'
|
||||||
|
cmds:
|
||||||
|
- task: assemble:aab:release
|
||||||
|
|
||||||
|
bundle:fat:
|
||||||
|
summary: Packages a production AAB for all architectures
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
vars:
|
||||||
|
PRODUCTION: "true"
|
||||||
|
ARCH: arm64
|
||||||
|
cmds:
|
||||||
|
- task: compile:go:shared
|
||||||
|
vars:
|
||||||
|
ARCH: amd64
|
||||||
|
PRODUCTION: "true"
|
||||||
|
- task: assemble:aab:release
|
||||||
|
|
||||||
|
assemble:apk:
|
||||||
|
summary: Assembles a debug APK using Gradle
|
||||||
|
preconditions:
|
||||||
|
- sh: 'command -v java >/dev/null || [ -n "$JAVA_HOME" ]'
|
||||||
|
msg: "Java not found. Install a JDK (e.g. brew install openjdk@21) and/or set JAVA_HOME"
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
cd build/android
|
||||||
|
# The exec bit is lost when gradlew is extracted from the embedded
|
||||||
|
# build assets, so restore it before invoking the wrapper.
|
||||||
|
chmod +x ./gradlew
|
||||||
|
./gradlew assembleDebug
|
||||||
|
cp app/build/outputs/apk/debug/app-debug.apk "../../{{.BIN_DIR}}/{{.APP_NAME}}.apk"
|
||||||
|
echo "APK created: {{.BIN_DIR}}/{{.APP_NAME}}.apk"
|
||||||
|
|
||||||
|
assemble:apk:release:
|
||||||
|
summary: Assembles a release APK using Gradle (signed with the debug keystore unless ANDROID_KEYSTORE_FILE is set)
|
||||||
|
preconditions:
|
||||||
|
- sh: 'command -v java >/dev/null || [ -n "$JAVA_HOME" ]'
|
||||||
|
msg: "Java not found. Install a JDK (e.g. brew install openjdk@21) and/or set JAVA_HOME"
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
cd build/android
|
||||||
|
# The exec bit is lost when gradlew is extracted from the embedded
|
||||||
|
# build assets, so restore it before invoking the wrapper.
|
||||||
|
chmod +x ./gradlew
|
||||||
|
./gradlew assembleRelease
|
||||||
|
cp app/build/outputs/apk/release/app-release.apk "../../{{.BIN_DIR}}/{{.APP_NAME}}.apk"
|
||||||
|
echo "Release APK created: {{.BIN_DIR}}/{{.APP_NAME}}.apk"
|
||||||
|
|
||||||
|
assemble:aab:
|
||||||
|
summary: Assembles a debug AAB (Android App Bundle) using Gradle
|
||||||
|
preconditions:
|
||||||
|
- sh: 'command -v java >/dev/null || [ -n "$JAVA_HOME" ]'
|
||||||
|
msg: "Java not found. Install a JDK (e.g. brew install openjdk@21) and/or set JAVA_HOME"
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
cd build/android
|
||||||
|
# The exec bit is lost when gradlew is extracted from the embedded
|
||||||
|
# build assets, so restore it before invoking the wrapper.
|
||||||
|
chmod +x ./gradlew
|
||||||
|
./gradlew bundleDebug
|
||||||
|
cp app/build/outputs/bundle/debug/app-debug.aab "../../{{.BIN_DIR}}/{{.APP_NAME}}.aab"
|
||||||
|
echo "AAB created: {{.BIN_DIR}}/{{.APP_NAME}}.aab"
|
||||||
|
|
||||||
|
assemble:aab:release:
|
||||||
|
summary: Assembles a release AAB for Play Store upload (signed with the debug keystore unless ANDROID_KEYSTORE_FILE is set)
|
||||||
|
preconditions:
|
||||||
|
- sh: 'command -v java >/dev/null || [ -n "$JAVA_HOME" ]'
|
||||||
|
msg: "Java not found. Install a JDK (e.g. brew install openjdk@21) and/or set JAVA_HOME"
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
# With Play App Signing, the keystore configured here is your UPLOAD
|
||||||
|
# key: Google verifies the upload with it, then re-signs the app with
|
||||||
|
# the app signing key it manages for distribution.
|
||||||
|
if [ -z "$ANDROID_KEYSTORE_FILE" ]; then
|
||||||
|
echo "WARNING: ANDROID_KEYSTORE_FILE is not set, so this AAB will be"
|
||||||
|
echo "signed with the debug keystore. Google Play rejects debug-signed"
|
||||||
|
echo "bundles. Set ANDROID_KEYSTORE_FILE, ANDROID_KEYSTORE_PASSWORD,"
|
||||||
|
echo "ANDROID_KEY_ALIAS and ANDROID_KEY_PASSWORD before uploading."
|
||||||
|
fi
|
||||||
|
cd build/android
|
||||||
|
# The exec bit is lost when gradlew is extracted from the embedded
|
||||||
|
# build assets, so restore it before invoking the wrapper.
|
||||||
|
chmod +x ./gradlew
|
||||||
|
./gradlew bundleRelease
|
||||||
|
cp app/build/outputs/bundle/release/app-release.aab "../../{{.BIN_DIR}}/{{.APP_NAME}}.aab"
|
||||||
|
echo "Release AAB created: {{.BIN_DIR}}/{{.APP_NAME}}.aab"
|
||||||
|
|
||||||
|
generate:android:overlay:
|
||||||
|
internal: true
|
||||||
|
summary: Generate Go build overlay that registers the Android main
|
||||||
|
sources:
|
||||||
|
- build/config.yml
|
||||||
|
generates:
|
||||||
|
- build/android/overlay.json
|
||||||
|
- build/android/gen/main_android.gen.go
|
||||||
|
cmds:
|
||||||
|
- wails3 android overlay:gen -out build/android/overlay.json -config build/config.yml
|
||||||
|
|
||||||
|
generate:android:bindings:
|
||||||
|
internal: true
|
||||||
|
summary: Generates bindings for Android
|
||||||
|
sources:
|
||||||
|
- "**/*.go"
|
||||||
|
- go.mod
|
||||||
|
- go.sum
|
||||||
|
generates:
|
||||||
|
- frontend/bindings/**/*
|
||||||
|
cmds:
|
||||||
|
# Bindings are generated from the Go AST; CGO is disabled so the NDK
|
||||||
|
# is not required for this step
|
||||||
|
- wails3 generate bindings -f '-tags android' -clean=true
|
||||||
|
env:
|
||||||
|
GOOS: android
|
||||||
|
CGO_ENABLED: 0
|
||||||
|
|
||||||
|
ensure-emulator:
|
||||||
|
internal: true
|
||||||
|
summary: Ensure Android Emulator is running
|
||||||
|
silent: true
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
# Check if an emulator is already running
|
||||||
|
if "{{.ADB}}" devices | grep -q "emulator"; then
|
||||||
|
echo "Emulator already running"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get first available AVD
|
||||||
|
AVD_NAME=$("{{.EMULATOR}}" -list-avds | tail -1)
|
||||||
|
if [ -z "$AVD_NAME" ]; then
|
||||||
|
# No AVD yet. Mirror the iOS `ensure-simulator` flow and create one
|
||||||
|
# automatically — but ONLY from a system image that is already
|
||||||
|
# installed. We never trigger an sdkmanager download from a `run`
|
||||||
|
# task (that would be a surprise multi-GB download + license prompt).
|
||||||
|
# Pick the highest-API installed image matching the host ABI.
|
||||||
|
SDK_ROOT="{{.SDK_ROOT}}"
|
||||||
|
ABI="{{.ANDROID_ABI}}"
|
||||||
|
IMG=$(ls -d "$SDK_ROOT"/system-images/android-*/*/"$ABI" 2>/dev/null | sort -V | tail -1)
|
||||||
|
AVDMANAGER="{{.AVDMANAGER}}"
|
||||||
|
if [ -n "$IMG" ] && [ -x "$AVDMANAGER" ]; then
|
||||||
|
PKG="system-images;$(echo "$IMG" | sed "s|$SDK_ROOT/system-images/||" | tr '/' ';')"
|
||||||
|
echo "No Android Virtual Devices found. Creating 'wails' from $PKG..."
|
||||||
|
echo "no" | "$AVDMANAGER" create avd --name wails --package "$PKG" --device pixel_7 --force
|
||||||
|
AVD_NAME=wails
|
||||||
|
else
|
||||||
|
echo "No Android Virtual Devices found, and no system image is installed to create one from."
|
||||||
|
echo "Install a system image and create an AVD, e.g.:"
|
||||||
|
echo " sdkmanager 'system-images;android-35;google_apis;$ABI'"
|
||||||
|
echo " avdmanager create avd --name wails --package 'system-images;android-35;google_apis;$ABI' --device pixel_7"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting emulator: $AVD_NAME"
|
||||||
|
# Start the emulator daemonized so it outlives this task step. go-task's
|
||||||
|
# shell tracks background jobs by PID and reaps them when the command's
|
||||||
|
# interpreter finishes (which nohup/setsid alone don't prevent — the kill
|
||||||
|
# is direct), so a bare `emulator &` is gone before the later
|
||||||
|
# install/launch steps run. Launch it from a short-lived child shell that
|
||||||
|
# backgrounds the emulator and exits immediately: the emulator is then
|
||||||
|
# reparented to init/launchd and go-task's shell has no handle to reap it.
|
||||||
|
nohup sh -c "'{{.EMULATOR}}' -avd '$AVD_NAME' -no-snapshot-load </dev/null >/dev/null 2>&1 &" >/dev/null 2>&1
|
||||||
|
|
||||||
|
# Wait for emulator to boot (max 120 seconds)
|
||||||
|
echo "Waiting for emulator to boot..."
|
||||||
|
"{{.ADB}}" wait-for-device
|
||||||
|
|
||||||
|
for i in $(seq 1 120); do
|
||||||
|
BOOT_COMPLETED=$("{{.ADB}}" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')
|
||||||
|
if [ "$BOOT_COMPLETED" = "1" ]; then
|
||||||
|
echo "Emulator booted successfully"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Emulator boot timeout"
|
||||||
|
exit 1
|
||||||
|
preconditions:
|
||||||
|
- sh: '[ -x "{{.ADB}}" ] || command -v adb'
|
||||||
|
msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)"
|
||||||
|
- sh: '[ -x "{{.EMULATOR}}" ] || command -v emulator'
|
||||||
|
msg: "emulator not found. Install the Android SDK emulator package (or set ANDROID_HOME)"
|
||||||
|
|
||||||
|
deploy-emulator:
|
||||||
|
summary: Deploy the packaged release APK to the Android Emulator
|
||||||
|
deps:
|
||||||
|
- task: package
|
||||||
|
vars:
|
||||||
|
ARCH: '{{.ARCH | default .HOST_ARCH}}'
|
||||||
|
cmds:
|
||||||
|
- task: ensure-emulator
|
||||||
|
- '"{{.ADB}}" uninstall {{.APP_ID}} 2>/dev/null || true'
|
||||||
|
- '"{{.ADB}}" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"'
|
||||||
|
- '"{{.ADB}}" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity'
|
||||||
|
|
||||||
|
run:
|
||||||
|
summary: Build, install and launch a debug build in the Android Emulator
|
||||||
|
deps:
|
||||||
|
- task: ensure-emulator
|
||||||
|
- task: build
|
||||||
|
cmds:
|
||||||
|
- task: assemble:apk
|
||||||
|
- '"{{.ADB}}" uninstall {{.APP_ID}} 2>/dev/null || true'
|
||||||
|
- '"{{.ADB}}" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"'
|
||||||
|
- '"{{.ADB}}" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity'
|
||||||
|
|
||||||
|
device:list:
|
||||||
|
summary: Lists connected Android devices and emulators (serials)
|
||||||
|
cmds:
|
||||||
|
- '"{{.ADB}}" devices -l'
|
||||||
|
|
||||||
|
run:device:
|
||||||
|
summary: Build, install and launch a debug build on a connected physical Android device
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
vars:
|
||||||
|
ARCH: arm64
|
||||||
|
cmds:
|
||||||
|
- task: assemble:apk
|
||||||
|
- |
|
||||||
|
DEVICE='{{.DEVICE_ID | default ""}}'
|
||||||
|
if [ -z "$DEVICE" ]; then
|
||||||
|
DEVICE="${DEVICE_ID:-}"
|
||||||
|
fi
|
||||||
|
if [ -z "$DEVICE" ]; then
|
||||||
|
DEVICE=$("{{.ADB}}" devices | awk 'NR > 1 && $2 == "device" && $1 !~ /^emulator-/ { print $1; exit }')
|
||||||
|
fi
|
||||||
|
if [ -z "$DEVICE" ]; then
|
||||||
|
echo "Error: no connected physical Android device found."
|
||||||
|
echo "Pass DEVICE_ID=<serial> to target a device explicitly."
|
||||||
|
echo "Find connected device serials with: {{.ADB}} devices"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Deploying {{.BIN_DIR}}/{{.APP_NAME}}.apk to device $DEVICE..."
|
||||||
|
"{{.ADB}}" -s "$DEVICE" uninstall {{.APP_ID}} 2>/dev/null || true
|
||||||
|
"{{.ADB}}" -s "$DEVICE" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"
|
||||||
|
"{{.ADB}}" -s "$DEVICE" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity
|
||||||
|
preconditions:
|
||||||
|
- sh: '[ -x "{{.ADB}}" ] || command -v adb'
|
||||||
|
msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)"
|
||||||
|
|
||||||
|
deploy-device:
|
||||||
|
summary: Deploy the packaged release APK to a connected physical Android device
|
||||||
|
deps:
|
||||||
|
- task: package
|
||||||
|
vars:
|
||||||
|
ARCH: arm64
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
DEVICE='{{.DEVICE_ID | default ""}}'
|
||||||
|
if [ -z "$DEVICE" ]; then
|
||||||
|
DEVICE="${DEVICE_ID:-}"
|
||||||
|
fi
|
||||||
|
if [ -z "$DEVICE" ]; then
|
||||||
|
DEVICE=$("{{.ADB}}" devices | awk 'NR > 1 && $2 == "device" && $1 !~ /^emulator-/ { print $1; exit }')
|
||||||
|
fi
|
||||||
|
if [ -z "$DEVICE" ]; then
|
||||||
|
echo "Error: no connected physical Android device found."
|
||||||
|
echo "Pass DEVICE_ID=<serial> to target a device explicitly."
|
||||||
|
echo "Find connected device serials with: {{.ADB}} devices"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Deploying {{.BIN_DIR}}/{{.APP_NAME}}.apk to device $DEVICE..."
|
||||||
|
"{{.ADB}}" -s "$DEVICE" uninstall {{.APP_ID}} 2>/dev/null || true
|
||||||
|
"{{.ADB}}" -s "$DEVICE" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"
|
||||||
|
"{{.ADB}}" -s "$DEVICE" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity
|
||||||
|
preconditions:
|
||||||
|
- sh: '[ -x "{{.ADB}}" ] || command -v adb'
|
||||||
|
msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)"
|
||||||
|
|
||||||
|
studio:
|
||||||
|
summary: Open the generated Android project in Android Studio
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
if command -v studio >/dev/null 2>&1; then
|
||||||
|
studio build/android
|
||||||
|
elif [ -d "/Applications/Android Studio.app" ]; then
|
||||||
|
open -a "Android Studio" build/android
|
||||||
|
else
|
||||||
|
echo "Android Studio not found. Install it from https://developer.android.com/studio,"
|
||||||
|
echo "then open the build/android directory."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
logs:
|
||||||
|
summary: Stream Android logcat filtered to this app
|
||||||
|
cmds:
|
||||||
|
- '"{{.ADB}}" logcat -v time | grep -E "(Wails|{{.APP_NAME}})" || true'
|
||||||
|
|
||||||
|
logs:all:
|
||||||
|
summary: Stream all Android logcat (verbose)
|
||||||
|
cmds:
|
||||||
|
- '"{{.ADB}}" logcat -v time'
|
||||||
|
|
||||||
|
clean:
|
||||||
|
summary: Clean build artifacts
|
||||||
|
cmds:
|
||||||
|
- rm -rf {{.BIN_DIR}}
|
||||||
|
- rm -rf build/android/app/build
|
||||||
|
- rm -rf build/android/app/src/main/jniLibs/*/libwails.so
|
||||||
|
- rm -rf build/android/.gradle
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
|
||||||
|
# Keep native methods
|
||||||
|
-keepclasseswithmembernames class * {
|
||||||
|
native <methods>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep Wails bridge classes
|
||||||
|
-keep class com.wails.app.WailsBridge { *; }
|
||||||
|
-keep class com.wails.app.WailsJSBridge { *; }
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<!-- Internet permission for WebView -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
<!-- Observe network connectivity / type for android:NetworkChanged events -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Playback has to survive the screen locking, and that needs a
|
||||||
|
foreground service typed mediaPlayback rather than dataSync. The
|
||||||
|
type in the <service> element and the permission here must agree,
|
||||||
|
or startForeground throws at runtime.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Reading the user's music.
|
||||||
|
|
||||||
|
READ_MEDIA_AUDIO is the Android 13+ grant and READ_EXTERNAL_STORAGE
|
||||||
|
is its predecessor, capped so it is not requested where it no
|
||||||
|
longer applies. Both give access through **MediaStore**.
|
||||||
|
|
||||||
|
MANAGE_EXTERNAL_STORAGE is what gives access through the
|
||||||
|
*filesystem*, and this app needs it rather than merely preferring
|
||||||
|
it: `audio_files.file_path` is the primary key of ownership, the
|
||||||
|
scanner walks a directory the user chose, and every
|
||||||
|
GetFilePathsBy... query exists to hand a path to the player.
|
||||||
|
MediaStore offers no stable directory to walk and no way to write
|
||||||
|
a tag back in place, so the alternative is not "more work" but a
|
||||||
|
different application.
|
||||||
|
|
||||||
|
It is a Play-restricted permission, granted on a Settings screen
|
||||||
|
rather than in a dialog. That is acceptable *here* only because
|
||||||
|
this app is distributed as an APK through the package registry and
|
||||||
|
not through Play — see docs/android-release.md. If it ever targets
|
||||||
|
Play, this is the line that has to go, and plan 016 says what
|
||||||
|
would replace it.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="29" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||||
|
tools:ignore="ScopedStorage" />
|
||||||
|
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.media.action.IMAGE_CAPTURE" />
|
||||||
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.media.action.VIDEO_CAPTURE" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.WailsApp"
|
||||||
|
tools:targetApi="31">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:configChanges="orientation|screenSize|keyboardHidden|uiMode"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
mediaPlayback, not the scaffold's dataSync: this app's reason
|
||||||
|
for staying alive in the background is that a song is
|
||||||
|
playing, and Android matches the declared type against what
|
||||||
|
the service actually does.
|
||||||
|
-->
|
||||||
|
<service
|
||||||
|
android:name=".WailsForegroundService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="mediaPlayback" />
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,967 @@
|
|||||||
|
package com.wails.app;
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint;
|
||||||
|
import android.content.BroadcastReceiver;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.IntentFilter;
|
||||||
|
import android.content.res.Configuration;
|
||||||
|
import android.database.Cursor;
|
||||||
|
import android.net.ConnectivityManager;
|
||||||
|
import android.net.Network;
|
||||||
|
import android.net.NetworkCapabilities;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.Manifest;
|
||||||
|
import android.os.BatteryManager;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.Environment;
|
||||||
|
import android.os.PowerManager;
|
||||||
|
import android.provider.Settings;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.graphics.BitmapFactory;
|
||||||
|
import android.provider.MediaStore;
|
||||||
|
import android.provider.OpenableColumns;
|
||||||
|
import android.util.Base64;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.webkit.WebResourceRequest;
|
||||||
|
import android.webkit.WebResourceResponse;
|
||||||
|
import android.webkit.WebSettings;
|
||||||
|
import android.webkit.WebView;
|
||||||
|
import android.webkit.WebViewClient;
|
||||||
|
|
||||||
|
import android.view.View;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
import androidx.core.content.FileProvider;
|
||||||
|
import androidx.core.graphics.Insets;
|
||||||
|
import androidx.core.view.ViewCompat;
|
||||||
|
import androidx.core.view.WindowInsetsCompat;
|
||||||
|
import androidx.core.view.WindowCompat;
|
||||||
|
import androidx.core.view.WindowInsetsControllerCompat;
|
||||||
|
import androidx.webkit.WebViewAssetLoader;
|
||||||
|
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MainActivity hosts the WebView and manages the Wails application lifecycle.
|
||||||
|
* It uses WebViewAssetLoader to serve assets from the Go library without
|
||||||
|
* requiring a network server.
|
||||||
|
*/
|
||||||
|
public class MainActivity extends AppCompatActivity {
|
||||||
|
private static final String TAG = "WailsActivity";
|
||||||
|
private static final boolean DEBUG = BuildConfig.DEBUG;
|
||||||
|
private static final String WAILS_SCHEME = "https";
|
||||||
|
private static final String WAILS_HOST = "wails.localhost";
|
||||||
|
private static final int FILE_PICKER_REQUEST = 7001;
|
||||||
|
|
||||||
|
private WebView webView;
|
||||||
|
private WailsBridge bridge;
|
||||||
|
// Battery: system-event receivers are registered only while the activity is
|
||||||
|
// in the foreground (onStart) and torn down in onStop, so background battery/
|
||||||
|
// network/screen broadcasts don't wake the app.
|
||||||
|
private boolean systemReceiversRegistered = false;
|
||||||
|
private WebViewAssetLoader assetLoader;
|
||||||
|
|
||||||
|
// The Go-side dialog ID of the in-flight file picker (-1 when idle)
|
||||||
|
private int pendingFilePickerCallbackID = -1;
|
||||||
|
private static final int PHOTO_CAPTURE_REQUEST = 7002;
|
||||||
|
private static final int VIDEO_CAPTURE_REQUEST = 7003;
|
||||||
|
private static final int CAMERA_PERMISSION_REQUEST = 7010;
|
||||||
|
private File pendingCaptureFile;
|
||||||
|
private boolean pendingCaptureIsVideo;
|
||||||
|
|
||||||
|
// System-event sources (battery/power, screen lock, network). Registered in
|
||||||
|
// onCreate, torn down in onDestroy. Each forwards a "system:*" event to JS
|
||||||
|
// via the bridge.
|
||||||
|
private BroadcastReceiver batteryReceiver;
|
||||||
|
private BroadcastReceiver screenReceiver;
|
||||||
|
private BroadcastReceiver powerSaveReceiver;
|
||||||
|
private ConnectivityManager connectivityManager;
|
||||||
|
private ConnectivityManager.NetworkCallback networkCallback;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_main);
|
||||||
|
|
||||||
|
// Before anything renders: the page is laid out inside the
|
||||||
|
// window, and on Android 15 the window is the whole screen.
|
||||||
|
applyWindowInsets();
|
||||||
|
|
||||||
|
// Initialize the native Go library
|
||||||
|
bridge = new WailsBridge(this);
|
||||||
|
bridge.initialize();
|
||||||
|
|
||||||
|
// Ask for access to the user's music before the frontend has
|
||||||
|
// anything to say about it. See ensureStorageAccess().
|
||||||
|
ensureStorageAccess();
|
||||||
|
|
||||||
|
// Set up WebView
|
||||||
|
setupWebView();
|
||||||
|
|
||||||
|
// Load the application
|
||||||
|
loadApplication();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtain access to the user's music.
|
||||||
|
*
|
||||||
|
* <p>This app is a library manager: its database is keyed on file
|
||||||
|
* paths, its scanner walks a directory the user chose, and its tag
|
||||||
|
* writer rewrites files in place. MediaStore offers none of those,
|
||||||
|
* so the app holds MANAGE_EXTERNAL_STORAGE — which is granted on a
|
||||||
|
* Settings screen rather than in a dialog, and therefore cannot be
|
||||||
|
* requested with requestPermissions().
|
||||||
|
*
|
||||||
|
* <p>The screen is opened on every cold start until access exists,
|
||||||
|
* because without it the app can see nothing at all and there is no
|
||||||
|
* degraded mode worth offering. Returning from it lands in
|
||||||
|
* onResume, which re-checks and tells the frontend.
|
||||||
|
*
|
||||||
|
* <p>Below Android 11 there is no all-files concept and plain
|
||||||
|
* READ_EXTERNAL_STORAGE is both sufficient and a normal dialog.
|
||||||
|
*/
|
||||||
|
private void ensureStorageAccess() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
if (Environment.isExternalStorageManager()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The per-app screen is the one that can actually grant it.
|
||||||
|
// A few OEM builds do not implement it, so fall back to the
|
||||||
|
// global list rather than leaving the user with nothing.
|
||||||
|
try {
|
||||||
|
startActivity(new Intent(
|
||||||
|
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
|
||||||
|
Uri.parse("package:" + getPackageName())));
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "per-app all-files screen unavailable: " + e.getMessage());
|
||||||
|
try {
|
||||||
|
startActivity(new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION));
|
||||||
|
} catch (Exception e2) {
|
||||||
|
Log.w(TAG, "no all-files settings screen at all: " + e2.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED) {
|
||||||
|
requestPermissions(new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, 1010);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the app can currently read the user's music, by the same
|
||||||
|
* test the Go side uses.
|
||||||
|
*/
|
||||||
|
private boolean hasStorageAccess() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
return Environment.isExternalStorageManager();
|
||||||
|
}
|
||||||
|
return checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||||
|
== PackageManager.PERMISSION_GRANTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the frontend whether music is readable. Emitted on resume
|
||||||
|
* rather than only at startup, because the grant happens on a
|
||||||
|
* Settings screen in another task and the way back is a resume.
|
||||||
|
*/
|
||||||
|
private void emitStorageAccess() {
|
||||||
|
if (bridge == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bridge.emitEvent("android:storageAccess",
|
||||||
|
"{\"granted\":" + (hasStorageAccess() ? "true" : "false") + "}");
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
private void setupWebView() {
|
||||||
|
webView = findViewById(R.id.webview);
|
||||||
|
bridge.setWebView(webView);
|
||||||
|
|
||||||
|
// Configure WebView settings
|
||||||
|
WebSettings settings = webView.getSettings();
|
||||||
|
settings.setJavaScriptEnabled(true);
|
||||||
|
settings.setDomStorageEnabled(true);
|
||||||
|
settings.setDatabaseEnabled(true);
|
||||||
|
settings.setAllowFileAccess(false);
|
||||||
|
settings.setAllowContentAccess(false);
|
||||||
|
settings.setMediaPlaybackRequiresUserGesture(false);
|
||||||
|
settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW);
|
||||||
|
|
||||||
|
// Enable debugging in debug builds
|
||||||
|
if (DEBUG) {
|
||||||
|
WebView.setWebContentsDebuggingEnabled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up asset loader for serving local assets
|
||||||
|
assetLoader = new WebViewAssetLoader.Builder()
|
||||||
|
.setDomain(WAILS_HOST)
|
||||||
|
.addPathHandler("/", new WailsPathHandler(bridge))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Set up WebView client to intercept requests
|
||||||
|
webView.setWebViewClient(new WebViewClient() {
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
|
||||||
|
// Handle wails.localhost requests
|
||||||
|
if (request.getUrl().getHost() != null &&
|
||||||
|
request.getUrl().getHost().equals(WAILS_HOST)) {
|
||||||
|
|
||||||
|
// For wails API calls (runtime, capabilities, etc.) pass the
|
||||||
|
// full URL including the query string, because
|
||||||
|
// WebViewAssetLoader.PathHandler strips query params
|
||||||
|
String path = request.getUrl().getPath();
|
||||||
|
if (path != null && path.startsWith("/wails/")) {
|
||||||
|
String fullPath = path;
|
||||||
|
String query = request.getUrl().getQuery();
|
||||||
|
if (query != null && !query.isEmpty()) {
|
||||||
|
fullPath = path + "?" + query;
|
||||||
|
}
|
||||||
|
if (DEBUG) Log.d(TAG, "Wails API call: " + fullPath);
|
||||||
|
|
||||||
|
byte[] data = bridge.serveAsset(fullPath, request.getMethod(), "{}");
|
||||||
|
if (data != null && data.length > 0) {
|
||||||
|
java.io.InputStream inputStream = new java.io.ByteArrayInputStream(data);
|
||||||
|
java.util.Map<String, String> headers = new java.util.HashMap<>();
|
||||||
|
headers.put("Access-Control-Allow-Origin", "*");
|
||||||
|
headers.put("Cache-Control", "no-cache");
|
||||||
|
headers.put("Content-Type", "application/json");
|
||||||
|
|
||||||
|
return new WebResourceResponse(
|
||||||
|
"application/json",
|
||||||
|
"UTF-8",
|
||||||
|
200,
|
||||||
|
"OK",
|
||||||
|
headers,
|
||||||
|
inputStream
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Return error response if data is null
|
||||||
|
return new WebResourceResponse(
|
||||||
|
"application/json",
|
||||||
|
"UTF-8",
|
||||||
|
500,
|
||||||
|
"Internal Error",
|
||||||
|
new java.util.HashMap<>(),
|
||||||
|
new java.io.ByteArrayInputStream("{}".getBytes())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream captured photos/videos from the cache with HTTP Range
|
||||||
|
// support so <video> can seek/stream a clip of any length.
|
||||||
|
if (path != null && path.startsWith("/__capture__/")) {
|
||||||
|
return serveCaptureFile(path.substring("/__capture__/".length()), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For regular assets, use the asset loader
|
||||||
|
return assetLoader.shouldInterceptRequest(request.getUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.shouldInterceptRequest(view, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPageFinished(WebView view, String url) {
|
||||||
|
super.onPageFinished(view, url);
|
||||||
|
if (DEBUG) Log.d(TAG, "Page loaded: " + url);
|
||||||
|
bridge.onPageFinished(url);
|
||||||
|
// Now that JS listeners are mounted, push a snapshot of the
|
||||||
|
// current battery / network / theme so the UI starts populated.
|
||||||
|
emitSystemSnapshot();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add JavaScript interface for Go communication
|
||||||
|
webView.addJavascriptInterface(new WailsJSBridge(bridge, webView), "wails");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadApplication() {
|
||||||
|
String url = WAILS_SCHEME + "://" + WAILS_HOST + "/";
|
||||||
|
if (DEBUG) Log.d(TAG, "Loading URL: " + url);
|
||||||
|
webView.loadUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch the system camera to capture a photo (video=false) or a video
|
||||||
|
* (video=true). The capture is written to a FileProvider URI in the cache and
|
||||||
|
* the result is delivered to JS as a "common:capture" event.
|
||||||
|
*/
|
||||||
|
public void launchCameraCapture(boolean video) {
|
||||||
|
if (checkSelfPermission("android.permission.CAMERA") != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
pendingCaptureIsVideo = video;
|
||||||
|
requestPermissions(new String[]{"android.permission.CAMERA"}, CAMERA_PERMISSION_REQUEST);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
File dir = new File(getCacheDir(), "captures");
|
||||||
|
if (!dir.exists()) dir.mkdirs();
|
||||||
|
pendingCaptureFile = new File(dir, "capture_" + System.currentTimeMillis() + (video ? ".mp4" : ".jpg"));
|
||||||
|
pendingCaptureIsVideo = video;
|
||||||
|
Uri uri = FileProvider.getUriForFile(this, getPackageName() + ".fileprovider", pendingCaptureFile);
|
||||||
|
Intent intent = new Intent(video ? MediaStore.ACTION_VIDEO_CAPTURE : MediaStore.ACTION_IMAGE_CAPTURE);
|
||||||
|
intent.putExtra(MediaStore.EXTRA_OUTPUT, uri);
|
||||||
|
intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
|
||||||
|
// Don't pre-check with resolveActivity(): Android 11+ package visibility
|
||||||
|
// hides other apps' intents unless declared in <queries>, so it can
|
||||||
|
// return null even when a camera app exists. Just launch and handle a miss.
|
||||||
|
startActivityForResult(intent, video ? VIDEO_CAPTURE_REQUEST : PHOTO_CAPTURE_REQUEST);
|
||||||
|
} catch (android.content.ActivityNotFoundException e) {
|
||||||
|
bridge.emitEvent("common:capture", "{\"error\":\"no camera app available\"}");
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "launchCameraCapture failed", e);
|
||||||
|
bridge.emitEvent("common:capture", "{\"error\":\"capture failed\"}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
|
||||||
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||||
|
if (requestCode == CAMERA_PERMISSION_REQUEST) {
|
||||||
|
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
launchCameraCapture(pendingCaptureIsVideo);
|
||||||
|
} else {
|
||||||
|
bridge.emitEvent("common:capture", "{\"error\":\"camera permission denied\"}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (bridge != null) {
|
||||||
|
bridge.onRequestPermissionsResult(requestCode, grantResults);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleCaptureResult(int resultCode, @Nullable Intent data) {
|
||||||
|
File file = pendingCaptureFile;
|
||||||
|
final boolean video = pendingCaptureIsVideo;
|
||||||
|
pendingCaptureFile = null;
|
||||||
|
if (resultCode != RESULT_OK) {
|
||||||
|
bridge.emitEvent("common:capture", "{\"cancelled\":true}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Some camera apps (commonly for video) ignore EXTRA_OUTPUT and instead
|
||||||
|
// return a content URI in the result data; copy that into our cache.
|
||||||
|
if ((file == null || !file.exists() || file.length() == 0)
|
||||||
|
&& data != null && data.getData() != null) {
|
||||||
|
String copied = copyUriToCache(data.getData());
|
||||||
|
if (copied != null) file = new File(copied);
|
||||||
|
}
|
||||||
|
final File f = file;
|
||||||
|
if (f == null || !f.exists() || f.length() == 0) {
|
||||||
|
bridge.emitEvent("common:capture", "{\"cancelled\":true}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
new Thread(() -> {
|
||||||
|
try {
|
||||||
|
JSONObject o = new JSONObject();
|
||||||
|
o.put("type", video ? "video" : "photo");
|
||||||
|
o.put("path", f.getAbsolutePath());
|
||||||
|
o.put("size", f.length());
|
||||||
|
if (!video) {
|
||||||
|
String thumb = makePhotoThumbnail(f);
|
||||||
|
if (thumb != null) o.put("thumb", thumb);
|
||||||
|
}
|
||||||
|
// Stream URL works for both: <video>/<img> load it from the cache
|
||||||
|
// via shouldInterceptRequest (Range-enabled), no size limit.
|
||||||
|
o.put("streamUrl", captureStreamUrl(f));
|
||||||
|
bridge.emitEvent("common:capture", o.toString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "handleCaptureResult failed", e);
|
||||||
|
bridge.emitEvent("common:capture", "{\"error\":\"result processing failed\"}");
|
||||||
|
}
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Downscale a captured photo into a base64 JPEG data URL for display in the webview. */
|
||||||
|
@Nullable
|
||||||
|
private String makePhotoThumbnail(File file) {
|
||||||
|
try {
|
||||||
|
BitmapFactory.Options bounds = new BitmapFactory.Options();
|
||||||
|
bounds.inJustDecodeBounds = true;
|
||||||
|
BitmapFactory.decodeFile(file.getAbsolutePath(), bounds);
|
||||||
|
int sample = 1;
|
||||||
|
while (Math.max(bounds.outWidth, bounds.outHeight) / sample > 640) sample *= 2;
|
||||||
|
BitmapFactory.Options opts = new BitmapFactory.Options();
|
||||||
|
opts.inSampleSize = sample;
|
||||||
|
Bitmap bmp = BitmapFactory.decodeFile(file.getAbsolutePath(), opts);
|
||||||
|
if (bmp == null) return null;
|
||||||
|
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
|
bmp.compress(Bitmap.CompressFormat.JPEG, 70, baos);
|
||||||
|
bmp.recycle();
|
||||||
|
return "data:image/jpeg;base64," + Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a same-origin URL the webview can stream a capture from. Served by
|
||||||
|
* serveCaptureFile (via shouldInterceptRequest); the path is relative to the
|
||||||
|
* cache dir so both camera files (captures/) and copied content URIs
|
||||||
|
* (wails-picker/) resolve.
|
||||||
|
*/
|
||||||
|
private String captureStreamUrl(File file) {
|
||||||
|
String base = getCacheDir().getAbsolutePath() + File.separator;
|
||||||
|
String abs = file.getAbsolutePath();
|
||||||
|
String rel = abs.startsWith(base) ? abs.substring(base.length()) : file.getName();
|
||||||
|
return "/__capture__/" + Uri.encode(rel, "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve a captured file (under the app cache) to the webview with HTTP Range
|
||||||
|
* support, so <video> can stream and seek a clip of any length without
|
||||||
|
* inlining it as a data URL.
|
||||||
|
*/
|
||||||
|
private WebResourceResponse serveCaptureFile(String relPath, WebResourceRequest request) {
|
||||||
|
try {
|
||||||
|
File cache = getCacheDir();
|
||||||
|
File file = new File(cache, Uri.decode(relPath));
|
||||||
|
// Path-traversal guard: only ever serve files under the cache dir.
|
||||||
|
if (!file.getCanonicalPath().startsWith(cache.getCanonicalPath() + File.separator)
|
||||||
|
|| !file.exists() || !file.isFile()) {
|
||||||
|
return new WebResourceResponse("text/plain", "UTF-8", 404, "Not Found",
|
||||||
|
new java.util.HashMap<>(), new java.io.ByteArrayInputStream(new byte[0]));
|
||||||
|
}
|
||||||
|
String name = file.getName().toLowerCase();
|
||||||
|
String mime = name.endsWith(".mp4") ? "video/mp4"
|
||||||
|
: name.endsWith(".mov") ? "video/quicktime"
|
||||||
|
: name.endsWith(".jpg") || name.endsWith(".jpeg") ? "image/jpeg"
|
||||||
|
: name.endsWith(".png") ? "image/png" : "application/octet-stream";
|
||||||
|
long length = file.length();
|
||||||
|
java.util.Map<String, String> reqHeaders = request.getRequestHeaders();
|
||||||
|
String range = reqHeaders != null ? reqHeaders.get("Range") : null;
|
||||||
|
if (range == null && reqHeaders != null) range = reqHeaders.get("range");
|
||||||
|
|
||||||
|
java.util.Map<String, String> headers = new java.util.HashMap<>();
|
||||||
|
headers.put("Accept-Ranges", "bytes");
|
||||||
|
headers.put("Cache-Control", "no-store");
|
||||||
|
|
||||||
|
if (range != null && range.startsWith("bytes=")) {
|
||||||
|
long start = 0, end = length - 1;
|
||||||
|
String spec = range.substring(6).trim();
|
||||||
|
int dash = spec.indexOf('-');
|
||||||
|
if (dash >= 0) {
|
||||||
|
try {
|
||||||
|
if (dash > 0) start = Long.parseLong(spec.substring(0, dash).trim());
|
||||||
|
String e = spec.substring(dash + 1).trim();
|
||||||
|
if (!e.isEmpty()) end = Long.parseLong(e);
|
||||||
|
} catch (NumberFormatException ignored) { }
|
||||||
|
}
|
||||||
|
if (start < 0) start = 0;
|
||||||
|
if (end >= length) end = length - 1;
|
||||||
|
if (start > end) { start = 0; end = length - 1; }
|
||||||
|
long count = end - start + 1;
|
||||||
|
java.io.InputStream in = new java.io.FileInputStream(file);
|
||||||
|
long toSkip = start;
|
||||||
|
while (toSkip > 0) {
|
||||||
|
long s = in.skip(toSkip);
|
||||||
|
if (s <= 0) break;
|
||||||
|
toSkip -= s;
|
||||||
|
}
|
||||||
|
headers.put("Content-Range", "bytes " + start + "-" + end + "/" + length);
|
||||||
|
headers.put("Content-Length", String.valueOf(count));
|
||||||
|
return new WebResourceResponse(mime, null, 206, "Partial Content",
|
||||||
|
headers, new LimitedInputStream(in, count));
|
||||||
|
}
|
||||||
|
headers.put("Content-Length", String.valueOf(length));
|
||||||
|
return new WebResourceResponse(mime, null, 200, "OK", headers,
|
||||||
|
new java.io.FileInputStream(file));
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "serveCaptureFile failed", e);
|
||||||
|
return new WebResourceResponse("text/plain", "UTF-8", 500, "Error",
|
||||||
|
new java.util.HashMap<>(), new java.io.ByteArrayInputStream(new byte[0]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wraps a stream to yield at most a fixed number of bytes (for Range responses). */
|
||||||
|
private static final class LimitedInputStream extends java.io.FilterInputStream {
|
||||||
|
private long remaining;
|
||||||
|
LimitedInputStream(java.io.InputStream in, long limit) {
|
||||||
|
super(in);
|
||||||
|
this.remaining = limit;
|
||||||
|
}
|
||||||
|
@Override public int read() throws java.io.IOException {
|
||||||
|
if (remaining <= 0) return -1;
|
||||||
|
int b = super.read();
|
||||||
|
if (b >= 0) remaining--;
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
@Override public int read(byte[] b, int off, int len) throws java.io.IOException {
|
||||||
|
if (remaining <= 0) return -1;
|
||||||
|
int n = super.read(b, off, (int) Math.min(len, remaining));
|
||||||
|
if (n > 0) remaining -= n;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch the system document picker. Results are copied into the app's
|
||||||
|
* cache directory so Go receives real filesystem paths. Called by
|
||||||
|
* WailsBridge on the main thread.
|
||||||
|
*/
|
||||||
|
public void launchFilePicker(int callbackID, boolean multiple) {
|
||||||
|
synchronized (this) {
|
||||||
|
if (pendingFilePickerCallbackID != -1) {
|
||||||
|
// Only one picker can be in flight
|
||||||
|
bridge.filePickerDone(callbackID);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingFilePickerCallbackID = callbackID;
|
||||||
|
}
|
||||||
|
|
||||||
|
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||||
|
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||||
|
intent.setType("*/*");
|
||||||
|
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multiple);
|
||||||
|
try {
|
||||||
|
startActivityForResult(intent, FILE_PICKER_REQUEST);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Failed to launch file picker", e);
|
||||||
|
pendingFilePickerCallbackID = -1;
|
||||||
|
bridge.filePickerDone(callbackID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
|
||||||
|
super.onActivityResult(requestCode, resultCode, data);
|
||||||
|
if (requestCode == PHOTO_CAPTURE_REQUEST || requestCode == VIDEO_CAPTURE_REQUEST) {
|
||||||
|
handleCaptureResult(resultCode, data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requestCode != FILE_PICKER_REQUEST) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final int callbackID = pendingFilePickerCallbackID;
|
||||||
|
pendingFilePickerCallbackID = -1;
|
||||||
|
if (callbackID == -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<Uri> uris = new ArrayList<>();
|
||||||
|
if (resultCode == RESULT_OK && data != null) {
|
||||||
|
if (data.getClipData() != null) {
|
||||||
|
for (int i = 0; i < data.getClipData().getItemCount(); i++) {
|
||||||
|
uris.add(data.getClipData().getItemAt(i).getUri());
|
||||||
|
}
|
||||||
|
} else if (data.getData() != null) {
|
||||||
|
uris.add(data.getData());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the documents off the main thread, then notify Go
|
||||||
|
new Thread(() -> {
|
||||||
|
for (Uri uri : uris) {
|
||||||
|
String path = copyUriToCache(uri);
|
||||||
|
if (path != null) {
|
||||||
|
bridge.filePickerResult(callbackID, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bridge.filePickerDone(callbackID);
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy a content URI into the app cache and return its filesystem path.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
private String copyUriToCache(Uri uri) {
|
||||||
|
String name = "document";
|
||||||
|
try (Cursor cursor = getContentResolver().query(uri, null, null, null, null)) {
|
||||||
|
if (cursor != null && cursor.moveToFirst()) {
|
||||||
|
int idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
||||||
|
if (idx >= 0 && cursor.getString(idx) != null) {
|
||||||
|
name = new File(cursor.getString(idx)).getName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
File dir = new File(getCacheDir(), "wails-picker/" + System.nanoTime());
|
||||||
|
if (!dir.mkdirs()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
File out = new File(dir, name);
|
||||||
|
try (InputStream in = getContentResolver().openInputStream(uri);
|
||||||
|
OutputStream os = new FileOutputStream(out)) {
|
||||||
|
if (in == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
byte[] buf = new byte[64 * 1024];
|
||||||
|
int n;
|
||||||
|
while ((n = in.read(buf)) > 0) {
|
||||||
|
os.write(buf, 0, n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.getAbsolutePath();
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Failed to copy picked document", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute JavaScript in the WebView from the Go side
|
||||||
|
*/
|
||||||
|
public void executeJavaScript(final String js) {
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
if (webView != null) {
|
||||||
|
webView.evaluateJavascript(js, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- System events ---------------------------------------------------
|
||||||
|
// Battery/power, screen lock and network connectivity are surfaced to JS as
|
||||||
|
// "system:*" events. The OS broadcasts used here (ACTION_BATTERY_CHANGED,
|
||||||
|
// SCREEN_OFF, USER_PRESENT, POWER_SAVE_MODE_CHANGED) are protected system
|
||||||
|
// broadcasts, so dynamic registration needs no RECEIVER_* export flag.
|
||||||
|
|
||||||
|
private void registerSystemEventReceivers() {
|
||||||
|
// Battery + charging state (sticky broadcast: the current value is
|
||||||
|
// delivered to the receiver immediately on registration).
|
||||||
|
batteryReceiver = new BroadcastReceiver() {
|
||||||
|
@Override public void onReceive(Context context, Intent intent) {
|
||||||
|
emitBattery(intent);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
registerReceiver(batteryReceiver, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
|
||||||
|
|
||||||
|
// Low-power (battery saver) mode toggles → re-emit battery with the flag.
|
||||||
|
powerSaveReceiver = new BroadcastReceiver() {
|
||||||
|
@Override public void onReceive(Context context, Intent intent) {
|
||||||
|
emitBattery(registerSticky(Intent.ACTION_BATTERY_CHANGED));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
registerReceiver(powerSaveReceiver,
|
||||||
|
new IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED));
|
||||||
|
|
||||||
|
// Screen lock / unlock. SCREEN_OFF ≈ locked; USER_PRESENT = unlocked.
|
||||||
|
screenReceiver = new BroadcastReceiver() {
|
||||||
|
@Override public void onReceive(Context context, Intent intent) {
|
||||||
|
String action = intent.getAction();
|
||||||
|
if (Intent.ACTION_SCREEN_OFF.equals(action)) {
|
||||||
|
emitLock(true);
|
||||||
|
} else if (Intent.ACTION_USER_PRESENT.equals(action)) {
|
||||||
|
emitLock(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
IntentFilter screenFilter = new IntentFilter();
|
||||||
|
screenFilter.addAction(Intent.ACTION_SCREEN_OFF);
|
||||||
|
screenFilter.addAction(Intent.ACTION_USER_PRESENT);
|
||||||
|
registerReceiver(screenReceiver, screenFilter);
|
||||||
|
|
||||||
|
// Network connectivity / transport type / cellular signal strength.
|
||||||
|
connectivityManager = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||||
|
if (connectivityManager != null) {
|
||||||
|
networkCallback = new ConnectivityManager.NetworkCallback() {
|
||||||
|
@Override public void onAvailable(Network network) { emitNetwork(network); }
|
||||||
|
@Override public void onLost(Network network) { emitNetworkDisconnected(); }
|
||||||
|
@Override public void onCapabilitiesChanged(Network network, NetworkCapabilities caps) {
|
||||||
|
emitNetwork(network);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
connectivityManager.registerDefaultNetworkCallback(networkCallback);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "registerDefaultNetworkCallback failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void unregisterSystemEventReceivers() {
|
||||||
|
safeUnregister(batteryReceiver);
|
||||||
|
batteryReceiver = null;
|
||||||
|
safeUnregister(powerSaveReceiver);
|
||||||
|
powerSaveReceiver = null;
|
||||||
|
safeUnregister(screenReceiver);
|
||||||
|
screenReceiver = null;
|
||||||
|
if (connectivityManager != null && networkCallback != null) {
|
||||||
|
try {
|
||||||
|
connectivityManager.unregisterNetworkCallback(networkCallback);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
networkCallback = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void safeUnregister(BroadcastReceiver r) {
|
||||||
|
if (r != null) {
|
||||||
|
try {
|
||||||
|
unregisterReceiver(r);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the current sticky value for an action without a standing receiver. */
|
||||||
|
@Nullable
|
||||||
|
private Intent registerSticky(String action) {
|
||||||
|
return registerReceiver(null, new IntentFilter(action));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push current battery / network / theme so a freshly-loaded UI is populated. */
|
||||||
|
private void emitSystemSnapshot() {
|
||||||
|
emitBattery(registerSticky(Intent.ACTION_BATTERY_CHANGED));
|
||||||
|
if (connectivityManager != null) {
|
||||||
|
Network active = connectivityManager.getActiveNetwork();
|
||||||
|
if (active != null) {
|
||||||
|
emitNetwork(active);
|
||||||
|
} else {
|
||||||
|
emitNetworkDisconnected();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emitTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void emitBattery(@Nullable Intent batteryStatus) {
|
||||||
|
try {
|
||||||
|
float level = -1f;
|
||||||
|
String state = "unknown";
|
||||||
|
if (batteryStatus != null) {
|
||||||
|
int lvl = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
|
||||||
|
int scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1);
|
||||||
|
if (lvl >= 0 && scale > 0) {
|
||||||
|
level = lvl / (float) scale;
|
||||||
|
}
|
||||||
|
switch (batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1)) {
|
||||||
|
case BatteryManager.BATTERY_STATUS_CHARGING: state = "charging"; break;
|
||||||
|
case BatteryManager.BATTERY_STATUS_FULL: state = "full"; break;
|
||||||
|
case BatteryManager.BATTERY_STATUS_DISCHARGING:
|
||||||
|
case BatteryManager.BATTERY_STATUS_NOT_CHARGING: state = "unplugged"; break;
|
||||||
|
default: state = "unknown"; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
boolean lowPower = false;
|
||||||
|
PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
|
||||||
|
if (pm != null) {
|
||||||
|
lowPower = pm.isPowerSaveMode();
|
||||||
|
}
|
||||||
|
JSONObject o = new JSONObject();
|
||||||
|
o.put("level", (double) level);
|
||||||
|
o.put("state", state);
|
||||||
|
o.put("lowPowerMode", lowPower);
|
||||||
|
if (bridge != null) bridge.emitSystemEvent("android:BatteryChanged", o.toString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "emitBattery failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void emitNetwork(@Nullable Network network) {
|
||||||
|
try {
|
||||||
|
boolean connected = false;
|
||||||
|
String type = "none";
|
||||||
|
boolean metered = false;
|
||||||
|
Integer signal = null;
|
||||||
|
if (connectivityManager != null && network != null) {
|
||||||
|
NetworkCapabilities caps = connectivityManager.getNetworkCapabilities(network);
|
||||||
|
if (caps != null) {
|
||||||
|
connected = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET);
|
||||||
|
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
||||||
|
type = "wifi";
|
||||||
|
} else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
|
||||||
|
type = "cellular";
|
||||||
|
} else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
|
||||||
|
type = "wired";
|
||||||
|
} else {
|
||||||
|
type = "other";
|
||||||
|
}
|
||||||
|
metered = !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED);
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
int s = caps.getSignalStrength();
|
||||||
|
if (s != Integer.MIN_VALUE) {
|
||||||
|
signal = s; // dBm; closer to 0 is a stronger signal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSONObject o = new JSONObject();
|
||||||
|
o.put("connected", connected);
|
||||||
|
o.put("type", type);
|
||||||
|
o.put("metered", metered);
|
||||||
|
if (signal != null) {
|
||||||
|
o.put("signal", (int) signal);
|
||||||
|
}
|
||||||
|
if (bridge != null) bridge.emitSystemEvent("android:NetworkChanged", o.toString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "emitNetwork failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void emitNetworkDisconnected() {
|
||||||
|
try {
|
||||||
|
JSONObject o = new JSONObject();
|
||||||
|
o.put("connected", false);
|
||||||
|
o.put("type", "none");
|
||||||
|
o.put("metered", false);
|
||||||
|
if (bridge != null) bridge.emitSystemEvent("android:NetworkChanged", o.toString());
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void emitLock(boolean locked) {
|
||||||
|
// Lock/unlock are signals (no payload); name carries the state.
|
||||||
|
if (bridge != null) {
|
||||||
|
bridge.emitSystemEvent(locked ? "android:ScreenLocked" : "android:ScreenUnlocked", "{}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void emitTheme() {
|
||||||
|
try {
|
||||||
|
int mode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
|
||||||
|
JSONObject o = new JSONObject();
|
||||||
|
// "isDarkMode" matches the context key the desktop platforms use.
|
||||||
|
o.put("isDarkMode", mode == Configuration.UI_MODE_NIGHT_YES);
|
||||||
|
if (bridge != null) bridge.emitSystemEvent("android:ThemeChanged", o.toString());
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onConfigurationChanged(Configuration newConfig) {
|
||||||
|
super.onConfigurationChanged(newConfig);
|
||||||
|
// Fires for light/dark switches because the manifest lists uiMode in
|
||||||
|
// android:configChanges (otherwise the activity would be recreated).
|
||||||
|
emitTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onStart() {
|
||||||
|
super.onStart();
|
||||||
|
// Battery: only monitor system events while the app is visible.
|
||||||
|
if (!systemReceiversRegistered) {
|
||||||
|
registerSystemEventReceivers();
|
||||||
|
systemReceiversRegistered = true;
|
||||||
|
}
|
||||||
|
if (bridge != null) {
|
||||||
|
bridge.onStart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
if (bridge != null) {
|
||||||
|
bridge.onResume();
|
||||||
|
}
|
||||||
|
// The all-files grant happens on a Settings screen in another
|
||||||
|
// task, so a resume is how the app finds out it was given.
|
||||||
|
emitStorageAccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onPause() {
|
||||||
|
super.onPause();
|
||||||
|
if (bridge != null) {
|
||||||
|
bridge.onPause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onStop() {
|
||||||
|
super.onStop();
|
||||||
|
if (systemReceiversRegistered) {
|
||||||
|
unregisterSystemEventReceivers();
|
||||||
|
systemReceiversRegistered = false;
|
||||||
|
}
|
||||||
|
if (bridge != null) {
|
||||||
|
bridge.onStop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLowMemory() {
|
||||||
|
super.onLowMemory();
|
||||||
|
if (bridge != null) {
|
||||||
|
bridge.onLowMemory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDestroy() {
|
||||||
|
super.onDestroy();
|
||||||
|
unregisterSystemEventReceivers();
|
||||||
|
if (bridge != null) {
|
||||||
|
bridge.shutdown();
|
||||||
|
}
|
||||||
|
if (webView != null) {
|
||||||
|
webView.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the web content inside the safe area.
|
||||||
|
*
|
||||||
|
* <p>targetSdk 35 is Android 15, which lays every app out
|
||||||
|
* edge-to-edge and ignores the {@code statusBarColor} and
|
||||||
|
* {@code navigationBarColor} this app's theme still sets. The
|
||||||
|
* WebView is {@code match_parent}, so the page's bottom band -- the
|
||||||
|
* transport and, on a phone, the tab bar -- was drawn underneath the
|
||||||
|
* gesture bar and reported from a device as "I can't see the
|
||||||
|
* playback controls, they seem to be off screen".
|
||||||
|
*
|
||||||
|
* <p>No web-tier test can see this: a browser viewport has no system
|
||||||
|
* bars, so the phone specs at 390x844 render a shell that fits
|
||||||
|
* while the device does not.
|
||||||
|
*
|
||||||
|
* <p>The insets are applied as padding and the window insets are
|
||||||
|
* returned rather than consumed, so the WebView is laid out inside
|
||||||
|
* them. {@code ime()} is in the mask because the same reasoning
|
||||||
|
* covers the keyboard: a focused search box that the keyboard
|
||||||
|
* covers is the same bug one surface over.
|
||||||
|
*/
|
||||||
|
private void applyWindowInsets() {
|
||||||
|
final View container = findViewById(R.id.main_container);
|
||||||
|
|
||||||
|
if (container == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(container, (view, windowInsets) -> {
|
||||||
|
Insets insets = windowInsets.getInsets(
|
||||||
|
WindowInsetsCompat.Type.systemBars()
|
||||||
|
| WindowInsetsCompat.Type.displayCutout()
|
||||||
|
| WindowInsetsCompat.Type.ime());
|
||||||
|
|
||||||
|
view.setPadding(insets.left, insets.top, insets.right, insets.bottom);
|
||||||
|
|
||||||
|
return windowInsets;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The padded band shows the window background, which is dark
|
||||||
|
// (this app's own default ramp is black), so the system's icons
|
||||||
|
// have to be the light set or they vanish into it. The theme is
|
||||||
|
// DayNight and would otherwise ask for dark icons in light mode.
|
||||||
|
WindowInsetsControllerCompat controller =
|
||||||
|
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
|
||||||
|
|
||||||
|
controller.setAppearanceLightStatusBars(false);
|
||||||
|
controller.setAppearanceLightNavigationBars(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBackPressed() {
|
||||||
|
// The frontend records every navigation as a history entry, so
|
||||||
|
// this is the app's own back stack: `canGoBack()` is false only
|
||||||
|
// at the launch entry, which is where back should leave.
|
||||||
|
if (webView != null && webView.canGoBack()) {
|
||||||
|
webView.goBack();
|
||||||
|
} else {
|
||||||
|
super.onBackPressed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
package com.wails.app;
|
||||||
|
|
||||||
|
import android.app.Notification;
|
||||||
|
import android.app.NotificationChannel;
|
||||||
|
import android.app.NotificationManager;
|
||||||
|
import android.app.PendingIntent;
|
||||||
|
import android.content.BroadcastReceiver;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.IntentFilter;
|
||||||
|
import android.content.pm.ServiceInfo;
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.graphics.BitmapFactory;
|
||||||
|
import android.media.AudioAttributes;
|
||||||
|
import android.media.AudioFocusRequest;
|
||||||
|
import android.media.AudioManager;
|
||||||
|
import android.media.MediaMetadata;
|
||||||
|
import android.media.session.MediaSession;
|
||||||
|
import android.media.session.PlaybackState;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.IBinder;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The foreground service that keeps playback alive with the screen off, and
|
||||||
|
* the app's whole media-control surface: a {@link MediaSession} for the lock
|
||||||
|
* screen and headset buttons, a transport notification, and audio focus.
|
||||||
|
*
|
||||||
|
* <p>The scaffold shipped this as a generic "keep the process alive" service
|
||||||
|
* typed {@code dataSync}. YellowJacket's reason for staying alive in the
|
||||||
|
* background is that a song is playing, so it is {@code mediaPlayback} — the
|
||||||
|
* manifest and {@code startForeground} must agree on that or the call throws.
|
||||||
|
*
|
||||||
|
* <p>It is driven entirely from Go. {@code backend/mediacontrols/android.go}
|
||||||
|
* pushes a JSON payload through
|
||||||
|
* {@link WailsBridge#startForegroundService(String)}, and every command the
|
||||||
|
* user gives here — a notification button, the lock screen, a headset, or the
|
||||||
|
* OS taking audio focus away — goes back the other way as a
|
||||||
|
* {@code yj:media:command} event. Nothing about playback is decided here: this
|
||||||
|
* class renders state and reports intent.
|
||||||
|
*/
|
||||||
|
public class WailsForegroundService extends android.app.Service {
|
||||||
|
public static final String ACTION_START = "com.wails.app.FGS_START";
|
||||||
|
|
||||||
|
// Transport actions, delivered to ourselves by the notification's
|
||||||
|
// PendingIntents. getService rather than a broadcast: a receiver would
|
||||||
|
// have to be exported or registered, and this service is already the
|
||||||
|
// thing that has to be running for any of them to be meaningful.
|
||||||
|
private static final String ACTION_PLAY = "com.wails.app.MEDIA_PLAY";
|
||||||
|
private static final String ACTION_PAUSE = "com.wails.app.MEDIA_PAUSE";
|
||||||
|
private static final String ACTION_NEXT = "com.wails.app.MEDIA_NEXT";
|
||||||
|
private static final String ACTION_PREVIOUS = "com.wails.app.MEDIA_PREVIOUS";
|
||||||
|
|
||||||
|
private static final String TAG = "WailsMedia";
|
||||||
|
private static final String CHANNEL_ID = "yellowjacket_playback";
|
||||||
|
private static final int NOTIFICATION_ID = 0x57A1; // "WAI"
|
||||||
|
private static final String COMMAND_EVENT = "yj:media:command";
|
||||||
|
|
||||||
|
/** Cover art is decoded down to this, which is larger than any lock screen. */
|
||||||
|
private static final int ART_MAX_PX = 512;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an instance is alive. {@link WailsBridge} reads it to decide
|
||||||
|
* between startForegroundService and startService: from Android 12 an app
|
||||||
|
* in the background may not <em>start</em> a foreground service, but it
|
||||||
|
* may go on delivering intents to one it already has — and every update
|
||||||
|
* after the first (a track change with the screen off, most of them) is
|
||||||
|
* exactly that case.
|
||||||
|
*/
|
||||||
|
static volatile boolean running = false;
|
||||||
|
|
||||||
|
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||||
|
private final ExecutorService artExecutor = Executors.newSingleThreadExecutor();
|
||||||
|
|
||||||
|
private MediaSession session;
|
||||||
|
private AudioManager audioManager;
|
||||||
|
private AudioFocusRequest focusRequest; // API 26+ only.
|
||||||
|
private AudioManager.OnAudioFocusChangeListener focusListener;
|
||||||
|
|
||||||
|
private String title = "";
|
||||||
|
private String artist = "";
|
||||||
|
private String album = "";
|
||||||
|
private String artPath = "";
|
||||||
|
private long durationMs = 0;
|
||||||
|
private long positionMs = 0;
|
||||||
|
private boolean playing = false;
|
||||||
|
|
||||||
|
private Bitmap art;
|
||||||
|
|
||||||
|
private boolean hasFocus = false;
|
||||||
|
/**
|
||||||
|
* Whether *we* paused because focus went away. Only then does regaining it
|
||||||
|
* resume: a user who paused during a phone call did not ask us to start
|
||||||
|
* again when it ended.
|
||||||
|
*/
|
||||||
|
private boolean pausedByFocusLoss = false;
|
||||||
|
|
||||||
|
private boolean noisyRegistered = false;
|
||||||
|
|
||||||
|
/** Headphones pulled out. Anything else and the room hears the album. */
|
||||||
|
private final BroadcastReceiver noisyReceiver = new BroadcastReceiver() {
|
||||||
|
@Override
|
||||||
|
public void onReceive(Context context, Intent intent) {
|
||||||
|
if (AudioManager.ACTION_AUDIO_BECOMING_NOISY.equals(intent.getAction())) {
|
||||||
|
emitCommand("pause");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreate() {
|
||||||
|
super.onCreate();
|
||||||
|
running = true;
|
||||||
|
audioManager = (AudioManager) getSystemService(AUDIO_SERVICE);
|
||||||
|
createChannel();
|
||||||
|
createSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||||
|
String action = intent == null ? null : intent.getAction();
|
||||||
|
|
||||||
|
if (ACTION_PLAY.equals(action)) {
|
||||||
|
emitCommand("play");
|
||||||
|
} else if (ACTION_PAUSE.equals(action)) {
|
||||||
|
emitCommand("pause");
|
||||||
|
} else if (ACTION_NEXT.equals(action)) {
|
||||||
|
emitCommand("next");
|
||||||
|
} else if (ACTION_PREVIOUS.equals(action)) {
|
||||||
|
emitCommand("previous");
|
||||||
|
} else if (intent != null) {
|
||||||
|
applyPayload(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unconditionally, on every path: a service started with
|
||||||
|
// startForegroundService that returns from onStartCommand without
|
||||||
|
// calling startForeground is killed with a
|
||||||
|
// ForegroundServiceDidNotStartInTimeException.
|
||||||
|
goForeground();
|
||||||
|
|
||||||
|
return START_STICKY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the state Go pushed. "payload" is the whole JSON document; the
|
||||||
|
* title/text extras are the scaffold's original contract and are kept as a
|
||||||
|
* fallback so a non-media caller still gets a sensible notification.
|
||||||
|
*/
|
||||||
|
private void applyPayload(Intent intent) {
|
||||||
|
String payload = intent.getStringExtra("payload");
|
||||||
|
if (payload == null || payload.isEmpty()) {
|
||||||
|
if (intent.getStringExtra("title") != null) {
|
||||||
|
title = intent.getStringExtra("title");
|
||||||
|
}
|
||||||
|
if (intent.getStringExtra("text") != null) {
|
||||||
|
artist = intent.getStringExtra("text");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
JSONObject o = new JSONObject(payload);
|
||||||
|
title = o.optString("title", "");
|
||||||
|
artist = o.optString("artist", "");
|
||||||
|
album = o.optString("album", "");
|
||||||
|
durationMs = o.optLong("durationSec", 0) * 1000L;
|
||||||
|
positionMs = o.optLong("positionSec", 0) * 1000L;
|
||||||
|
playing = "playing".equals(o.optString("state", "paused"));
|
||||||
|
|
||||||
|
String path = o.optString("artPath", "");
|
||||||
|
if (!path.equals(artPath)) {
|
||||||
|
artPath = path;
|
||||||
|
loadArt(path);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "bad media payload", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playing) {
|
||||||
|
requestFocus();
|
||||||
|
registerNoisy();
|
||||||
|
} else {
|
||||||
|
unregisterNoisy();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MediaSession ------------------------------------------------------
|
||||||
|
|
||||||
|
private void createSession() {
|
||||||
|
session = new MediaSession(this, "YellowJacket");
|
||||||
|
session.setFlags(MediaSession.FLAG_HANDLES_MEDIA_BUTTONS
|
||||||
|
| MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS);
|
||||||
|
session.setCallback(new MediaSession.Callback() {
|
||||||
|
@Override
|
||||||
|
public void onPlay() {
|
||||||
|
emitCommand("play");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPause() {
|
||||||
|
emitCommand("pause");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onStop() {
|
||||||
|
emitCommand("stop");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSkipToNext() {
|
||||||
|
emitCommand("next");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSkipToPrevious() {
|
||||||
|
emitCommand("previous");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSeekTo(long pos) {
|
||||||
|
try {
|
||||||
|
JSONObject o = new JSONObject();
|
||||||
|
o.put("command", "seek");
|
||||||
|
o.put("positionSec", pos / 1000L);
|
||||||
|
WailsBridge.emitFromService(COMMAND_EVENT, o.toString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "seek command failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
session.setActive(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateSession() {
|
||||||
|
MediaMetadata.Builder meta = new MediaMetadata.Builder()
|
||||||
|
.putString(MediaMetadata.METADATA_KEY_TITLE, title)
|
||||||
|
.putString(MediaMetadata.METADATA_KEY_ARTIST, artist)
|
||||||
|
.putString(MediaMetadata.METADATA_KEY_ALBUM, album)
|
||||||
|
.putLong(MediaMetadata.METADATA_KEY_DURATION, durationMs);
|
||||||
|
if (art != null) {
|
||||||
|
meta.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, art);
|
||||||
|
}
|
||||||
|
session.setMetadata(meta.build());
|
||||||
|
|
||||||
|
// The position is an anchor, not a clock: the state carries the
|
||||||
|
// playback speed and the OS interpolates from here, which is why the
|
||||||
|
// Go side only pushes on a real state change or a seek.
|
||||||
|
PlaybackState state = new PlaybackState.Builder()
|
||||||
|
.setActions(PlaybackState.ACTION_PLAY
|
||||||
|
| PlaybackState.ACTION_PAUSE
|
||||||
|
| PlaybackState.ACTION_PLAY_PAUSE
|
||||||
|
| PlaybackState.ACTION_STOP
|
||||||
|
| PlaybackState.ACTION_SKIP_TO_NEXT
|
||||||
|
| PlaybackState.ACTION_SKIP_TO_PREVIOUS
|
||||||
|
| PlaybackState.ACTION_SEEK_TO)
|
||||||
|
.setState(playing ? PlaybackState.STATE_PLAYING : PlaybackState.STATE_PAUSED,
|
||||||
|
positionMs, playing ? 1.0f : 0.0f)
|
||||||
|
.build();
|
||||||
|
session.setPlaybackState(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Notification ------------------------------------------------------
|
||||||
|
|
||||||
|
private void createChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
|
||||||
|
// LOW: a transport notification is a control surface, not news, and
|
||||||
|
// IMPORTANCE_DEFAULT would make a sound on every track change.
|
||||||
|
NotificationChannel ch = new NotificationChannel(
|
||||||
|
CHANNEL_ID, "Playback", NotificationManager.IMPORTANCE_LOW);
|
||||||
|
ch.setShowBadge(false);
|
||||||
|
nm.createNotificationChannel(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void goForeground() {
|
||||||
|
Notification n = buildNotification();
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(NOTIFICATION_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIFICATION_ID, n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
|
private Notification buildNotification() {
|
||||||
|
Notification.Builder b = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||||
|
? new Notification.Builder(this, CHANNEL_ID)
|
||||||
|
: new Notification.Builder(this);
|
||||||
|
|
||||||
|
b.setSmallIcon(android.R.drawable.ic_media_play)
|
||||||
|
.setContentTitle(title.isEmpty() ? getString(R.string.app_name) : title)
|
||||||
|
.setContentText(artist)
|
||||||
|
.setSubText(album)
|
||||||
|
.setOngoing(playing)
|
||||||
|
.setVisibility(Notification.VISIBILITY_PUBLIC)
|
||||||
|
.setContentIntent(launchIntent());
|
||||||
|
|
||||||
|
if (art != null) {
|
||||||
|
b.setLargeIcon(art);
|
||||||
|
}
|
||||||
|
|
||||||
|
b.addAction(new Notification.Action.Builder(
|
||||||
|
android.R.drawable.ic_media_previous, "Previous",
|
||||||
|
transportIntent(ACTION_PREVIOUS, 1)).build());
|
||||||
|
b.addAction(playing
|
||||||
|
? new Notification.Action.Builder(android.R.drawable.ic_media_pause, "Pause",
|
||||||
|
transportIntent(ACTION_PAUSE, 2)).build()
|
||||||
|
: new Notification.Action.Builder(android.R.drawable.ic_media_play, "Play",
|
||||||
|
transportIntent(ACTION_PLAY, 3)).build());
|
||||||
|
b.addAction(new Notification.Action.Builder(
|
||||||
|
android.R.drawable.ic_media_next, "Next",
|
||||||
|
transportIntent(ACTION_NEXT, 4)).build());
|
||||||
|
|
||||||
|
Notification.MediaStyle style = new Notification.MediaStyle()
|
||||||
|
.setShowActionsInCompactView(0, 1, 2);
|
||||||
|
if (session != null) {
|
||||||
|
style.setMediaSession(session.getSessionToken());
|
||||||
|
}
|
||||||
|
b.setStyle(style);
|
||||||
|
|
||||||
|
return b.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private PendingIntent transportIntent(String action, int requestCode) {
|
||||||
|
Intent i = new Intent(this, WailsForegroundService.class).setAction(action);
|
||||||
|
return PendingIntent.getService(this, requestCode, i, pendingIntentFlags());
|
||||||
|
}
|
||||||
|
|
||||||
|
private PendingIntent launchIntent() {
|
||||||
|
Intent launch = getPackageManager().getLaunchIntentForPackage(getPackageName());
|
||||||
|
if (launch == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return PendingIntent.getActivity(this, 0, launch, pendingIntentFlags());
|
||||||
|
}
|
||||||
|
|
||||||
|
private int pendingIntentFlags() {
|
||||||
|
// Mandatory from S, unavailable before M.
|
||||||
|
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
|
||||||
|
? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
: PendingIntent.FLAG_UPDATE_CURRENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cover art ---------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode the cover off the main thread and redraw when it lands. A track
|
||||||
|
* change must not wait on a JPEG, and the notification is correct without
|
||||||
|
* one — it simply has no image until this returns.
|
||||||
|
*/
|
||||||
|
private void loadArt(final String path) {
|
||||||
|
art = null;
|
||||||
|
if (path == null || path.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
artExecutor.execute(() -> {
|
||||||
|
Bitmap decoded = decodeScaled(path);
|
||||||
|
mainHandler.post(() -> {
|
||||||
|
// The track may have changed while we decoded.
|
||||||
|
if (!path.equals(artPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
art = decoded;
|
||||||
|
updateSession();
|
||||||
|
goForeground();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Bitmap decodeScaled(String path) {
|
||||||
|
try {
|
||||||
|
BitmapFactory.Options bounds = new BitmapFactory.Options();
|
||||||
|
bounds.inJustDecodeBounds = true;
|
||||||
|
BitmapFactory.decodeFile(path, bounds);
|
||||||
|
|
||||||
|
int longest = Math.max(bounds.outWidth, bounds.outHeight);
|
||||||
|
int sample = 1;
|
||||||
|
while (longest / sample > ART_MAX_PX) {
|
||||||
|
sample *= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
BitmapFactory.Options opts = new BitmapFactory.Options();
|
||||||
|
opts.inSampleSize = sample;
|
||||||
|
return BitmapFactory.decodeFile(path, opts);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
// OutOfMemoryError included: a missing cover is not a crash.
|
||||||
|
Log.w(TAG, "cover art decode failed: " + path, t);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Audio focus -------------------------------------------------------
|
||||||
|
|
||||||
|
private void requestFocus() {
|
||||||
|
if (hasFocus || audioManager == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focusListener == null) {
|
||||||
|
focusListener = this::onFocusChange;
|
||||||
|
}
|
||||||
|
|
||||||
|
int result;
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
AudioAttributes attrs = new AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||||
|
.build();
|
||||||
|
// No setWillPauseWhenDucked: from Oreo the framework ducks us
|
||||||
|
// itself and reports no CAN_DUCK loss, so the Go-side duck below
|
||||||
|
// is a pre-Oreo path. Asking to be told instead would mean
|
||||||
|
// pausing for every notification tone.
|
||||||
|
focusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||||
|
.setAudioAttributes(attrs)
|
||||||
|
.setOnAudioFocusChangeListener(focusListener, mainHandler)
|
||||||
|
.build();
|
||||||
|
result = audioManager.requestAudioFocus(focusRequest);
|
||||||
|
} else {
|
||||||
|
result = requestFocusLegacy();
|
||||||
|
}
|
||||||
|
|
||||||
|
hasFocus = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
|
private int requestFocusLegacy() {
|
||||||
|
return audioManager.requestAudioFocus(focusListener,
|
||||||
|
AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
|
private void abandonFocus() {
|
||||||
|
if (!hasFocus || audioManager == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && focusRequest != null) {
|
||||||
|
audioManager.abandonAudioFocusRequest(focusRequest);
|
||||||
|
} else {
|
||||||
|
audioManager.abandonAudioFocus(focusListener);
|
||||||
|
}
|
||||||
|
hasFocus = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onFocusChange(int change) {
|
||||||
|
switch (change) {
|
||||||
|
case AudioManager.AUDIOFOCUS_LOSS:
|
||||||
|
// Someone else owns the output now, for good.
|
||||||
|
hasFocus = false;
|
||||||
|
pausedByFocusLoss = false;
|
||||||
|
emitCommand("pause");
|
||||||
|
break;
|
||||||
|
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
|
||||||
|
// A phone call. Remember that the pause was ours to undo.
|
||||||
|
pausedByFocusLoss = playing;
|
||||||
|
emitCommand("pause");
|
||||||
|
break;
|
||||||
|
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
|
||||||
|
emitDuck(true);
|
||||||
|
break;
|
||||||
|
case AudioManager.AUDIOFOCUS_GAIN:
|
||||||
|
hasFocus = true;
|
||||||
|
emitDuck(false);
|
||||||
|
if (pausedByFocusLoss) {
|
||||||
|
pausedByFocusLoss = false;
|
||||||
|
emitCommand("play");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Noisy (headphones) ------------------------------------------------
|
||||||
|
|
||||||
|
private void registerNoisy() {
|
||||||
|
if (noisyRegistered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
registerReceiver(noisyReceiver,
|
||||||
|
new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY));
|
||||||
|
noisyRegistered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void unregisterNoisy() {
|
||||||
|
if (!noisyRegistered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
unregisterReceiver(noisyReceiver);
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
// Already gone; nothing to undo.
|
||||||
|
}
|
||||||
|
noisyRegistered = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Talking to Go -----------------------------------------------------
|
||||||
|
|
||||||
|
private void emitCommand(String command) {
|
||||||
|
try {
|
||||||
|
JSONObject o = new JSONObject();
|
||||||
|
o.put("command", command);
|
||||||
|
WailsBridge.emitFromService(COMMAND_EVENT, o.toString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "command emit failed: " + command, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void emitDuck(boolean on) {
|
||||||
|
try {
|
||||||
|
JSONObject o = new JSONObject();
|
||||||
|
o.put("command", "duck");
|
||||||
|
o.put("on", on);
|
||||||
|
WailsBridge.emitFromService(COMMAND_EVENT, o.toString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "duck emit failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroy() {
|
||||||
|
running = false;
|
||||||
|
unregisterNoisy();
|
||||||
|
abandonFocus();
|
||||||
|
if (session != null) {
|
||||||
|
session.setActive(false);
|
||||||
|
session.release();
|
||||||
|
session = null;
|
||||||
|
}
|
||||||
|
artExecutor.shutdownNow();
|
||||||
|
super.onDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public IBinder onBind(Intent intent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package com.wails.app;
|
||||||
|
|
||||||
|
import android.util.Log;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import android.webkit.JavascriptInterface;
|
||||||
|
import android.webkit.WebView;
|
||||||
|
import com.wails.app.BuildConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WailsJSBridge provides the JavaScript interface that allows the web frontend
|
||||||
|
* to communicate with the Go backend. This is exposed to JavaScript as the
|
||||||
|
* `window.wails` object.
|
||||||
|
*
|
||||||
|
* Similar to iOS's WKScriptMessageHandler but using Android's addJavascriptInterface.
|
||||||
|
*/
|
||||||
|
public class WailsJSBridge {
|
||||||
|
private static final String TAG = "WailsJSBridge";
|
||||||
|
private static final boolean DEBUG = BuildConfig.DEBUG;
|
||||||
|
// Pooled threads avoid unbounded thread creation under high call volume.
|
||||||
|
private static final ExecutorService executor = Executors.newCachedThreadPool();
|
||||||
|
|
||||||
|
private final WailsBridge bridge;
|
||||||
|
private final WebView webView;
|
||||||
|
|
||||||
|
public WailsJSBridge(WailsBridge bridge, WebView webView) {
|
||||||
|
this.bridge = bridge;
|
||||||
|
this.webView = webView;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to Go and return the response synchronously.
|
||||||
|
* Called from JavaScript: wails.invoke(message)
|
||||||
|
*
|
||||||
|
* @param message The message to send (JSON string)
|
||||||
|
* @return The response from Go (JSON string)
|
||||||
|
*/
|
||||||
|
@JavascriptInterface
|
||||||
|
public String invoke(String message) {
|
||||||
|
if (DEBUG) Log.d(TAG, "Invoke called: " + message);
|
||||||
|
return bridge.handleMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to Go asynchronously.
|
||||||
|
* The response will be sent back via a callback.
|
||||||
|
* Called from JavaScript: wails.invokeAsync(callbackId, message)
|
||||||
|
*
|
||||||
|
* @param callbackId The callback ID to use for the response
|
||||||
|
* @param message The message to send (JSON string)
|
||||||
|
*/
|
||||||
|
@JavascriptInterface
|
||||||
|
public void invokeAsync(final String callbackId, final String payload) {
|
||||||
|
if (DEBUG) Log.d(TAG, "InvokeAsync called: " + payload);
|
||||||
|
|
||||||
|
// Handle off the JS thread so we don't block the WebView.
|
||||||
|
executor.execute(() -> {
|
||||||
|
try {
|
||||||
|
String response = bridge.handleRuntimeCall(payload);
|
||||||
|
sendCallback(callbackId, response, null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error in async invoke", e);
|
||||||
|
sendCallback(callbackId, null, e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a message from JavaScript to Android's logcat
|
||||||
|
* Called from JavaScript: wails.log(level, message)
|
||||||
|
*
|
||||||
|
* @param level The log level (debug, info, warn, error)
|
||||||
|
* @param message The message to log
|
||||||
|
*/
|
||||||
|
@JavascriptInterface
|
||||||
|
public void log(String level, String message) {
|
||||||
|
switch (level.toLowerCase()) {
|
||||||
|
case "debug":
|
||||||
|
Log.d(TAG + "/JS", message);
|
||||||
|
break;
|
||||||
|
case "info":
|
||||||
|
Log.i(TAG + "/JS", message);
|
||||||
|
break;
|
||||||
|
case "warn":
|
||||||
|
Log.w(TAG + "/JS", message);
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
Log.e(TAG + "/JS", message);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
Log.v(TAG + "/JS", message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the platform name
|
||||||
|
* Called from JavaScript: wails.platform()
|
||||||
|
*
|
||||||
|
* @return "android"
|
||||||
|
*/
|
||||||
|
@JavascriptInterface
|
||||||
|
public String platform() {
|
||||||
|
return "android";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we're running in debug mode
|
||||||
|
* Called from JavaScript: wails.isDebug()
|
||||||
|
*
|
||||||
|
* @return true if debug build, false otherwise
|
||||||
|
*/
|
||||||
|
@JavascriptInterface
|
||||||
|
public boolean isDebug() {
|
||||||
|
return BuildConfig.DEBUG;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a callback response to JavaScript
|
||||||
|
*/
|
||||||
|
private void sendCallback(String callbackId, String result, String error) {
|
||||||
|
final String js;
|
||||||
|
if (error != null) {
|
||||||
|
js = String.format(
|
||||||
|
"window._wailsAndroidCallback && window._wailsAndroidCallback('%s', null, '%s');",
|
||||||
|
escapeJsString(callbackId),
|
||||||
|
escapeJsString(error)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
js = String.format(
|
||||||
|
"window._wailsAndroidCallback && window._wailsAndroidCallback('%s', '%s', null);",
|
||||||
|
escapeJsString(callbackId),
|
||||||
|
escapeJsString(result != null ? result : "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
webView.post(() -> webView.evaluateJavascript(js, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String escapeJsString(String str) {
|
||||||
|
if (str == null) return "";
|
||||||
|
return str.replace("\\", "\\\\")
|
||||||
|
.replace("'", "\\'")
|
||||||
|
.replace("\n", "\\n")
|
||||||
|
.replace("\r", "\\r")
|
||||||
|
// JS line terminators (U+2028/U+2029) must be escaped too; built via
|
||||||
|
// (char) casts so the Java lexer does not reinterpret them as newlines.
|
||||||
|
.replace(String.valueOf((char) 0x2028), "\\u2028")
|
||||||
|
.replace(String.valueOf((char) 0x2029), "\\u2029");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.wails.app;
|
||||||
|
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.webkit.WebResourceResponse;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.webkit.WebViewAssetLoader;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WailsPathHandler implements WebViewAssetLoader.PathHandler to serve assets
|
||||||
|
* from the Go asset server. This allows the WebView to load assets without
|
||||||
|
* using a network server, similar to iOS's WKURLSchemeHandler.
|
||||||
|
*/
|
||||||
|
public class WailsPathHandler implements WebViewAssetLoader.PathHandler {
|
||||||
|
private static final String TAG = "WailsPathHandler";
|
||||||
|
private static final boolean DEBUG = BuildConfig.DEBUG;
|
||||||
|
|
||||||
|
private final WailsBridge bridge;
|
||||||
|
|
||||||
|
public WailsPathHandler(WailsBridge bridge) {
|
||||||
|
this.bridge = bridge;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public WebResourceResponse handle(@NonNull String path) {
|
||||||
|
if (DEBUG) Log.d(TAG, "Handling path: " + path);
|
||||||
|
|
||||||
|
// Normalize path
|
||||||
|
if (path.isEmpty() || path.equals("/")) {
|
||||||
|
path = "/index.html";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get asset from Go
|
||||||
|
byte[] data = bridge.serveAsset(path, "GET", "{}");
|
||||||
|
|
||||||
|
if (data == null || data.length == 0) {
|
||||||
|
Log.w(TAG, "Asset not found: " + path);
|
||||||
|
return null; // Return null to let WebView handle 404
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine MIME type
|
||||||
|
String mimeType = bridge.getAssetMimeType(path);
|
||||||
|
if (DEBUG) Log.d(TAG, "Serving " + path + " with type " + mimeType + " (" + data.length + " bytes)");
|
||||||
|
|
||||||
|
// Create response
|
||||||
|
InputStream inputStream = new ByteArrayInputStream(data);
|
||||||
|
Map<String, String> headers = new HashMap<>();
|
||||||
|
headers.put("Access-Control-Allow-Origin", "*");
|
||||||
|
headers.put("Cache-Control", "no-cache");
|
||||||
|
|
||||||
|
return new WebResourceResponse(
|
||||||
|
mimeType,
|
||||||
|
"UTF-8",
|
||||||
|
200,
|
||||||
|
"OK",
|
||||||
|
headers,
|
||||||
|
inputStream
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/main_container"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<WebView
|
||||||
|
android:id="@+id/webview"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="wails_blue">#3574D4</color>
|
||||||
|
<color name="wails_blue_dark">#2C5FB8</color>
|
||||||
|
<!-- The window background, which is what the launch screen shows and
|
||||||
|
what the system-bar padding leaves visible. Black rather than the
|
||||||
|
scaffold's blue-grey because this app's own default ramp is
|
||||||
|
black: a band of #1B2636 above and below it reads as the app
|
||||||
|
failing to fill the screen. -->
|
||||||
|
<color name="wails_background">#000000</color>
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">YellowJacket</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.WailsApp" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||||
|
<!-- Primary brand color. -->
|
||||||
|
<item name="colorPrimary">@color/wails_blue</item>
|
||||||
|
<item name="colorPrimaryVariant">@color/wails_blue_dark</item>
|
||||||
|
<item name="colorOnPrimary">@android:color/white</item>
|
||||||
|
<!-- Status bar color. -->
|
||||||
|
<item name="android:statusBarColor">@color/wails_background</item>
|
||||||
|
<item name="android:navigationBarColor">@color/wails_background</item>
|
||||||
|
<!-- Window background -->
|
||||||
|
<item name="android:windowBackground">@color/wails_background</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<!-- Camera captures are written here and shared with the camera app via the
|
||||||
|
FileProvider. -->
|
||||||
|
<cache-path name="captures" path="captures/" />
|
||||||
|
</paths>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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" "$@"
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Println("Checking Android development dependencies...")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
errors := []string{}
|
||||||
|
|
||||||
|
// Check Go
|
||||||
|
if !checkCommand("go", "version") {
|
||||||
|
errors = append(errors, "Go is not installed. Install from https://go.dev/dl/")
|
||||||
|
} else {
|
||||||
|
fmt.Println("✓ Go is installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ANDROID_HOME
|
||||||
|
androidHome := os.Getenv("ANDROID_HOME")
|
||||||
|
if androidHome == "" {
|
||||||
|
androidHome = os.Getenv("ANDROID_SDK_ROOT")
|
||||||
|
}
|
||||||
|
if androidHome == "" {
|
||||||
|
// Try common default locations
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
possiblePaths := []string{
|
||||||
|
filepath.Join(home, "Android", "Sdk"),
|
||||||
|
filepath.Join(home, "Library", "Android", "sdk"),
|
||||||
|
"/usr/local/share/android-sdk",
|
||||||
|
}
|
||||||
|
for _, p := range possiblePaths {
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
androidHome = p
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if androidHome == "" {
|
||||||
|
errors = append(errors, "ANDROID_HOME not set. Install Android Studio and set ANDROID_HOME environment variable")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("✓ ANDROID_HOME: %s\n", androidHome)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check adb
|
||||||
|
if !checkCommand("adb", "version") {
|
||||||
|
if androidHome != "" {
|
||||||
|
platformTools := filepath.Join(androidHome, "platform-tools")
|
||||||
|
errors = append(errors, fmt.Sprintf("adb not found. Add %s to PATH", platformTools))
|
||||||
|
} else {
|
||||||
|
errors = append(errors, "adb not found. Install Android SDK Platform-Tools")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println("✓ adb is installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check emulator
|
||||||
|
if !checkCommand("emulator", "-list-avds") {
|
||||||
|
if androidHome != "" {
|
||||||
|
emulatorPath := filepath.Join(androidHome, "emulator")
|
||||||
|
errors = append(errors, fmt.Sprintf("emulator not found. Add %s to PATH", emulatorPath))
|
||||||
|
} else {
|
||||||
|
errors = append(errors, "emulator not found. Install Android Emulator via SDK Manager")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println("✓ Android Emulator is installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check NDK
|
||||||
|
ndkHome := os.Getenv("ANDROID_NDK_HOME")
|
||||||
|
if ndkHome == "" && androidHome != "" {
|
||||||
|
// Look for NDK in default location
|
||||||
|
ndkDir := filepath.Join(androidHome, "ndk")
|
||||||
|
if entries, err := os.ReadDir(ndkDir); err == nil {
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
ndkHome = filepath.Join(ndkDir, entry.Name())
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ndkHome == "" {
|
||||||
|
errors = append(errors, "Android NDK not found. Install NDK via Android Studio > SDK Manager > SDK Tools > NDK (Side by side)")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("✓ Android NDK: %s\n", ndkHome)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Java
|
||||||
|
if !checkCommand("java", "-version") {
|
||||||
|
errors = append(errors, "Java not found. Install JDK 11+ (OpenJDK recommended)")
|
||||||
|
} else {
|
||||||
|
fmt.Println("✓ Java is installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for AVD (Android Virtual Device)
|
||||||
|
if checkCommand("emulator", "-list-avds") {
|
||||||
|
cmd := exec.Command("emulator", "-list-avds")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err == nil && len(strings.TrimSpace(string(output))) > 0 {
|
||||||
|
avds := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||||
|
fmt.Printf("✓ Found %d Android Virtual Device(s)\n", len(avds))
|
||||||
|
} else {
|
||||||
|
// Mirror the iOS installer, which offers to create a simulator when
|
||||||
|
// none exist. Only create from an already-installed system image.
|
||||||
|
offerCreateAVD(androidHome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if len(errors) > 0 {
|
||||||
|
fmt.Println("❌ Missing dependencies:")
|
||||||
|
for _, err := range errors {
|
||||||
|
fmt.Printf(" - %s\n", err)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Setup instructions:")
|
||||||
|
fmt.Println("1. Install Android Studio: https://developer.android.com/studio")
|
||||||
|
fmt.Println("2. Open SDK Manager and install:")
|
||||||
|
fmt.Println(" - Android SDK Platform (API 35)")
|
||||||
|
fmt.Println(" - Android SDK Build-Tools")
|
||||||
|
fmt.Println(" - Android SDK Platform-Tools")
|
||||||
|
fmt.Println(" - Android Emulator")
|
||||||
|
fmt.Println(" - NDK (Side by side)")
|
||||||
|
fmt.Println("3. Set environment variables:")
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
fmt.Println(" export ANDROID_HOME=$HOME/Library/Android/sdk")
|
||||||
|
} else {
|
||||||
|
fmt.Println(" export ANDROID_HOME=$HOME/Android/Sdk")
|
||||||
|
}
|
||||||
|
fmt.Println(" export PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator")
|
||||||
|
fmt.Println("4. Create an AVD via Android Studio > Tools > Device Manager")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("✓ All Android development dependencies are installed!")
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkCommand(name string, args ...string) bool {
|
||||||
|
cmd := exec.Command(name, args...)
|
||||||
|
cmd.Stdout = nil
|
||||||
|
cmd.Stderr = nil
|
||||||
|
return cmd.Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// offerCreateAVD mirrors the iOS installer's simulator offer: when no AVD
|
||||||
|
// exists, offer to create one — but ONLY from a system image that is already
|
||||||
|
// installed. We never run sdkmanager here (a multi-GB download with a license
|
||||||
|
// prompt is a surprise the user should trigger themselves).
|
||||||
|
func offerCreateAVD(androidHome string) {
|
||||||
|
abi := "x86_64"
|
||||||
|
if runtime.GOARCH == "arm64" {
|
||||||
|
abi = "arm64-v8a"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the highest-API installed system image matching the host ABI.
|
||||||
|
// The API level must be compared numerically: lexicographic sorting
|
||||||
|
// would rank android-9 above android-35.
|
||||||
|
var img string
|
||||||
|
if androidHome != "" {
|
||||||
|
matches, _ := filepath.Glob(filepath.Join(androidHome, "system-images", "android-*", "*", abi))
|
||||||
|
bestAPI := -1
|
||||||
|
for _, m := range matches {
|
||||||
|
apiDir := filepath.Base(filepath.Dir(filepath.Dir(m)))
|
||||||
|
api, err := strconv.Atoi(strings.TrimPrefix(apiDir, "android-"))
|
||||||
|
if err != nil {
|
||||||
|
continue // preview/extension images (e.g. android-35-ext14)
|
||||||
|
}
|
||||||
|
if api > bestAPI {
|
||||||
|
bestAPI = api
|
||||||
|
img = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
avdmanager := findAVDManager(androidHome)
|
||||||
|
|
||||||
|
if img == "" || avdmanager == "" {
|
||||||
|
fmt.Println("⚠ No Android Virtual Devices found.")
|
||||||
|
fmt.Println(" Install a system image and create an AVD, e.g.:")
|
||||||
|
fmt.Printf(" sdkmanager 'system-images;android-35;google_apis;%s'\n", abi)
|
||||||
|
fmt.Printf(" avdmanager create avd --name wails --package 'system-images;android-35;google_apis;%s' --device pixel_7\n", abi)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive the package path from the installed image directory, e.g.
|
||||||
|
// <sdk>/system-images/android-35/google_apis/arm64-v8a
|
||||||
|
// -> system-images;android-35;google_apis;arm64-v8a
|
||||||
|
rel := strings.TrimPrefix(img, filepath.Join(androidHome, "system-images")+string(os.PathSeparator))
|
||||||
|
pkg := "system-images;" + strings.ReplaceAll(rel, string(os.PathSeparator), ";")
|
||||||
|
|
||||||
|
fmt.Println("⚠ No Android Virtual Devices found.")
|
||||||
|
fmt.Printf(" Would you like to create a 'wails' AVD from %s?\n", pkg)
|
||||||
|
if !promptUser("Create AVD?") {
|
||||||
|
fmt.Println(" Skipping AVD creation.")
|
||||||
|
fmt.Printf(" Create manually: avdmanager create avd --name wails --package '%s' --device pixel_7\n", pkg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(avdmanager, "create", "avd", "--name", "wails", "--package", pkg, "--device", "pixel_7", "--force")
|
||||||
|
cmd.Stdin = strings.NewReader("no\n") // decline the custom hardware-profile prompt
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
fmt.Printf(" Failed to create AVD: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println(" ✅ 'wails' AVD created")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findAVDManager returns the avdmanager path from PATH, or from the SDK's
|
||||||
|
// cmdline-tools (preferring the newest version), or "" if not found.
|
||||||
|
func findAVDManager(androidHome string) string {
|
||||||
|
if p, err := exec.LookPath("avdmanager"); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
if androidHome != "" {
|
||||||
|
matches, _ := filepath.Glob(filepath.Join(androidHome, "cmdline-tools", "*", "bin", "avdmanager"))
|
||||||
|
// Prefer the "latest" alias; otherwise compare versions numerically
|
||||||
|
// ("9.0" would lexicographically outrank "11.0").
|
||||||
|
best := ""
|
||||||
|
bestVersion := -1.0
|
||||||
|
for _, m := range matches {
|
||||||
|
version := filepath.Base(filepath.Dir(filepath.Dir(m)))
|
||||||
|
if version == "latest" {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseFloat(version, 64)
|
||||||
|
if err != nil {
|
||||||
|
v = 0
|
||||||
|
}
|
||||||
|
if v > bestVersion {
|
||||||
|
bestVersion = v
|
||||||
|
best = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptUser(question string) bool {
|
||||||
|
if os.Getenv("CI") != "" || os.Getenv("TASK_FORCE_YES") == "true" {
|
||||||
|
fmt.Printf("%s [y/N]: y (auto-accepted)\n", question)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
fmt.Printf("%s [y/N]: ", question)
|
||||||
|
response, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
response = strings.ToLower(strings.TrimSpace(response))
|
||||||
|
return response == "y" || response == "yes"
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "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,
|
release_name, primary_type, secondary_types, release_date, total_tracks,
|
||||||
artist_type, country, disambiguation, sort_name, discog_fetched`
|
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(
|
var errNoHome = errors.New(
|
||||||
"YJ_HOME must be set to the directory holding the built index",
|
"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
|
// dumpcatalog.go — a flat global top-N would give a handful of
|
||||||
// superstars everything and everyone else nothing.
|
// superstars everything and everyone else nothing.
|
||||||
func copyRows(db *sql.DB, artists, perArtistRGs, perArtistRecs int) error {
|
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(`
|
if _, err := db.Exec(`
|
||||||
CREATE TEMP TABLE core_artists AS
|
CREATE TEMP TABLE core_artists AS
|
||||||
SELECT mbid FROM main.explore_index
|
SELECT mbid FROM main.explore_index
|
||||||
@@ -221,7 +261,7 @@ func copyRows(db *sql.DB, artists, perArtistRGs, perArtistRecs int) error {
|
|||||||
|
|
||||||
copied, err := insertSelect(db, `
|
copied, err := insertSelect(db, `
|
||||||
INSERT INTO core.explore_index (`+catalogColumns+`)
|
INSERT INTO core.explore_index (`+catalogColumns+`)
|
||||||
SELECT `+catalogColumns+`
|
SELECT `+srcColumns+`
|
||||||
FROM main.explore_index
|
FROM main.explore_index
|
||||||
WHERE entity_type = 1 /* artist */
|
WHERE entity_type = 1 /* artist */
|
||||||
AND mbid IN (SELECT mbid FROM core_artists)`)
|
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.
|
// most `limit` rows, ranked by their own listen counts.
|
||||||
n, err := insertSelect(db, `
|
n, err := insertSelect(db, `
|
||||||
INSERT INTO core.explore_index (`+catalogColumns+`)
|
INSERT INTO core.explore_index (`+catalogColumns+`)
|
||||||
SELECT `+catalogColumns+` FROM (
|
SELECT `+srcColumns+` FROM (
|
||||||
SELECT *, ROW_NUMBER() OVER (
|
SELECT *, ROW_NUMBER() OVER (
|
||||||
PARTITION BY artist_mbid ORDER BY popularity DESC
|
PARTITION BY artist_mbid ORDER BY popularity DESC
|
||||||
) AS rn
|
) AS rn
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# Releasing the Android APK
|
||||||
|
|
||||||
|
`.gitea/workflows/android-apk.yml` builds a signed `arm64-v8a` APK on
|
||||||
|
every `v*` tag and publishes it to Gitea's
|
||||||
|
**generic** package registry, which is readable without credentials —
|
||||||
|
which is what lets Obtainium poll a plain URL with no token.
|
||||||
|
|
||||||
|
```
|
||||||
|
https://git.ljones.me/api/packages/yonlu/generic/yellowjacket-android/latest/yellowjacket.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
A versioned copy is kept alongside it at
|
||||||
|
`…/yellowjacket-android/<version>/yellowjacket-<version>.apk`.
|
||||||
|
|
||||||
|
The app on the device is **`app.yellowjacket`**. Its launcher activity is
|
||||||
|
`com.wails.app.MainActivity` — a different package, because that is the
|
||||||
|
Wails scaffold's Java package and renaming it would mean renaming its
|
||||||
|
source. Every `am start` needs the fully-qualified form.
|
||||||
|
|
||||||
|
## The signing key is the thing you cannot lose
|
||||||
|
|
||||||
|
**Android refuses to update an app whose signing certificate changed.**
|
||||||
|
There is no override and no recovery: the only way to install a build
|
||||||
|
signed with a different key is to uninstall first, which takes the
|
||||||
|
user's library, playlists and play counts with it. The key therefore
|
||||||
|
outlives every other secret in this repo.
|
||||||
|
|
||||||
|
Two consequences are wired into the workflow rather than left to
|
||||||
|
discipline. It **refuses to build** when `ANDROID_KEYSTORE_B64` is
|
||||||
|
absent, instead of falling through to Gradle's debug-keystore default —
|
||||||
|
a debug key differs between every machine and every CI runner, so a
|
||||||
|
build signed with one is un-updatable from the moment it is installed.
|
||||||
|
And it **refuses to publish** an APK whose certificate reads
|
||||||
|
`CN=Android Debug`, which is the same rule enforced one step later, on
|
||||||
|
the artifact rather than the configuration.
|
||||||
|
|
||||||
|
### Creating it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
keytool -genkeypair -v \
|
||||||
|
-keystore yellowjacket-release.jks \
|
||||||
|
-alias yellowjacket \
|
||||||
|
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||||
|
-storepass '<a long random password>' \
|
||||||
|
-dname "CN=YellowJacket, O=Shadow-Puppet, C=GB"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Do not pass `-keypass`.** keytool has produced PKCS12 keystores by
|
||||||
|
default since JDK 9 — the `.jks` extension does not change the format —
|
||||||
|
and PKCS12 cannot hold a key password distinct from the store password.
|
||||||
|
Given one it tells you so and ignores it:
|
||||||
|
|
||||||
|
```
|
||||||
|
Warning: Different store and key passwords not supported for PKCS12
|
||||||
|
KeyStores. Ignoring user-specified -keypass value.
|
||||||
|
```
|
||||||
|
|
||||||
|
So there is **one** password. Asking for a second is how someone sets a
|
||||||
|
wrong value and then debugs Gradle at midnight.
|
||||||
|
|
||||||
|
Back the `.jks` up somewhere that is not this repository and not this
|
||||||
|
server. Record the certificate fingerprint the build prints
|
||||||
|
(`Signer #1 certificate SHA-256 digest`); if it ever changes, updates
|
||||||
|
have already broken.
|
||||||
|
|
||||||
|
### The secrets
|
||||||
|
|
||||||
|
Repository → Settings → Actions → Secrets.
|
||||||
|
|
||||||
|
| Secret | Required | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `ANDROID_KEYSTORE_B64` | yes | `base64 -w0 yellowjacket-release.jks` |
|
||||||
|
| `ANDROID_KEYSTORE_PASSWORD` | yes | the `-storepass` above |
|
||||||
|
| `ANDROID_KEY_ALIAS` | no | defaults to `yellowjacket` |
|
||||||
|
| `ANDROID_KEY_PASSWORD` | no | defaults to the store password, and per the PKCS12 note it cannot differ |
|
||||||
|
| `PACKAGE_TOKEN` | already set | shared with `arch-package.yml`; publishes to the registry |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
base64 -w0 yellowjacket-release.jks # paste as ANDROID_KEYSTORE_B64
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cutting a release
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag v1.3.1
|
||||||
|
git push origin v1.3.1
|
||||||
|
```
|
||||||
|
|
||||||
|
That is the whole trigger. `homebrew-formula.yml` keys on the same tag,
|
||||||
|
so the desktop formula and the APK are cut together. The version code
|
||||||
|
Android orders releases by is derived from the tag — `1.3.1` → `10301`,
|
||||||
|
monotonic as long as minor and patch stay below 100 — so **do not
|
||||||
|
publish `1.100.0`**, and never move a tag that has already been built.
|
||||||
|
|
||||||
|
`workflow_dispatch` rebuilds without a new tag, taking an explicit
|
||||||
|
`version` input or falling back to the latest `v*` tag.
|
||||||
|
|
||||||
|
## What the workflow checks before publishing
|
||||||
|
|
||||||
|
- the APK exists and is non-empty;
|
||||||
|
- it carries **exactly one** ABI (`native-code: 'arm64-v8a'`). x86_64
|
||||||
|
Android cannot run this app at all — `modernc.org/libc` issues a raw
|
||||||
|
`lstat` syscall that Android's seccomp policy forbids, on every
|
||||||
|
x86_64 device and not merely the emulator — so an x86_64 slice would
|
||||||
|
be ~31 MB that runs nowhere, and its reappearance means someone put
|
||||||
|
the ABI back in `app/build.gradle` without knowing that;
|
||||||
|
- its `versionCode` is the one derived from the tag;
|
||||||
|
- it is **not** signed with the debug key.
|
||||||
|
|
||||||
|
The keystore is also opened with `keytool -list` before Gradle runs,
|
||||||
|
because Gradle only notices a bad password at
|
||||||
|
`:app:validateSigningRelease` — a minute of build time in — and reports
|
||||||
|
it as a missing file rather than a wrong password.
|
||||||
|
|
||||||
|
## Caches, and the first run
|
||||||
|
|
||||||
|
The job mounts four cache volumes; on a cold runner the first build is
|
||||||
|
slow and everything after it is not.
|
||||||
|
|
||||||
|
| Volume | Holds | Cold cost |
|
||||||
|
|---|---|---|
|
||||||
|
| `/cache/android-sdk` | SDK, platform, build-tools, NDK r26d | ~2 GB |
|
||||||
|
| `/cache/gradle` | `GRADLE_USER_HOME` — wrapper + AGP graph | ~700 MB |
|
||||||
|
| `/cache/tool` | the Go toolchain (shared with `ci.yml`) | ~200 MB |
|
||||||
|
| `/cache/pnpm-store` | pnpm store (shared with `ci.yml`) | — |
|
||||||
|
|
||||||
|
Every path must be inside the runner's `valid_volumes` allowlist. One
|
||||||
|
that is not makes the job **fail to start** rather than silently skip
|
||||||
|
the mount.
|
||||||
|
|
||||||
|
The NDK is pinned to **r26d** (`26.3.11579264`). Newer NDKs have broken
|
||||||
|
the Wails Android build before; it is a version, not a floor.
|
||||||
|
|
||||||
|
## Building one locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make android # unsigned-ish: debug key, versionCode 1, 0.0.0
|
||||||
|
YJ_VERSION=1.3.1 YJ_VERSION_CODE=10301 \
|
||||||
|
ANDROID_KEYSTORE_FILE=$PWD/yellowjacket-release.jks \
|
||||||
|
ANDROID_KEYSTORE_PASSWORD=... ANDROID_KEY_ALIAS=yellowjacket \
|
||||||
|
make android # what CI produces
|
||||||
|
```
|
||||||
|
|
||||||
|
Running it is a separate tier — see
|
||||||
|
`.pi/skills/yellowjacket-dev/references/android-tier.md`, and read its
|
||||||
|
first section before you try, because a failing Android build looks
|
||||||
|
exactly like a working one.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { test, expect } from '../support/fixtures.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Back is the platform's, and the app has to have somewhere for it to
|
||||||
|
* go (reported from a device: "the Android back button does not
|
||||||
|
* navigate back in the app").
|
||||||
|
*
|
||||||
|
* The scaffold's `MainActivity.onBackPressed` asks `webView.canGoBack()`
|
||||||
|
* and finishes the activity otherwise. This app never touched
|
||||||
|
* `history`, so that was always false and back quit from any depth. A
|
||||||
|
* navigation is a history entry now, which is why this is assertable
|
||||||
|
* here at all: `page.goBack()` is the same `popstate` the phone's
|
||||||
|
* gesture produces, so the browser tier can answer a question that
|
||||||
|
* otherwise needs a device.
|
||||||
|
*
|
||||||
|
* What it cannot answer is whether Android's *gesture* reaches the
|
||||||
|
* WebView, which is between the OS and the scaffold.
|
||||||
|
*/
|
||||||
|
type Page = import('@playwright/test').Page;
|
||||||
|
|
||||||
|
const activeView = (page: Page) =>
|
||||||
|
page.getByTestId('main-content');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open an artist's detail view, which is the deepest ordinary route.
|
||||||
|
*
|
||||||
|
* A library artist opens `explore-artist-details` -- the catalog panel
|
||||||
|
* standing in for a library one, as `explore-link.ts` describes -- and
|
||||||
|
* the view name follows the component, not the source of the click.
|
||||||
|
*/
|
||||||
|
async function openAnArtist(app: Page): Promise<void> {
|
||||||
|
await app.getByTestId('nav-artists').click();
|
||||||
|
await expect(activeView(app)).toHaveAttribute('data-active-view', 'artists');
|
||||||
|
|
||||||
|
// A card, by the name on it: the grid is virtualized and positioned
|
||||||
|
// by transform, so a click at coordinates is a click at whatever
|
||||||
|
// happens to be there.
|
||||||
|
await app.locator('artists-view').getByText('Aurora Fields').first().click();
|
||||||
|
await expect(activeView(app)).toHaveAttribute(
|
||||||
|
'data-active-view',
|
||||||
|
'explore-artist-details',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('the back gesture', () => {
|
||||||
|
test('leaves a detail view for the view it was opened from', async ({
|
||||||
|
app,
|
||||||
|
}) => {
|
||||||
|
await openAnArtist(app);
|
||||||
|
|
||||||
|
await app.goBack();
|
||||||
|
|
||||||
|
await expect(activeView(app)).toHaveAttribute('data-active-view', 'artists');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('walks back through primary views, one press per navigation', async ({
|
||||||
|
app,
|
||||||
|
}) => {
|
||||||
|
await app.getByTestId('nav-tracks').click();
|
||||||
|
await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks');
|
||||||
|
|
||||||
|
await app.getByTestId('nav-albums').click();
|
||||||
|
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
|
||||||
|
|
||||||
|
await app.goBack();
|
||||||
|
await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks');
|
||||||
|
|
||||||
|
// Forward is free once back works, and it is what proves the entry
|
||||||
|
// was restored rather than the view merely re-rendered.
|
||||||
|
await app.goForward();
|
||||||
|
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an in-app back button consumes exactly one entry', async ({ app }) => {
|
||||||
|
await app.getByTestId('nav-tracks').click();
|
||||||
|
await openAnArtist(app);
|
||||||
|
|
||||||
|
// The detail view's own back button and the phone's gesture are the
|
||||||
|
// same press: if each popped its own stack, this would land two
|
||||||
|
// navigations back instead of one.
|
||||||
|
await app
|
||||||
|
.locator('explore-artist-details')
|
||||||
|
.getByRole('button', { name: 'Back to explore' })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect(activeView(app)).toHaveAttribute('data-active-view', 'artists');
|
||||||
|
|
||||||
|
await app.goBack();
|
||||||
|
|
||||||
|
await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,9 +26,7 @@ import type { Page } from '@playwright/test';
|
|||||||
*/
|
*/
|
||||||
test.describe('Explore before anyone has typed', () => {
|
test.describe('Explore before anyone has typed', () => {
|
||||||
test.beforeEach(async ({ app }) => {
|
test.beforeEach(async ({ app }) => {
|
||||||
// Idempotent, so running it per test costs one count query when a
|
await stageCatalog(app);
|
||||||
// catalog is already there — which is every developer machine.
|
|
||||||
await stageCatalogIfEmpty(app);
|
|
||||||
|
|
||||||
await app.getByTestId('nav-explore').click();
|
await app.getByTestId('nav-explore').click();
|
||||||
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
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
|
* Give the app the catalog these shelves are written against.
|
||||||
* still exercises the shelves rather than skipping them.
|
|
||||||
*
|
*
|
||||||
* Deliberately shaped: two artists with albums (one of them with
|
* Deliberately shaped: two artists with albums (one of them with
|
||||||
* three), and a third with none. The three albums are what "one album
|
* 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
|
* above it and correctly skipped — the first version of this fixture
|
||||||
* had only two, and the artists shelf was rightly omitted, which read
|
* had only two, and the artists shelf was rightly omitted, which read
|
||||||
* as a broken page.
|
* as a broken page.
|
||||||
|
*
|
||||||
|
* **Unconditional, and it used to ask whether the catalog was empty.**
|
||||||
|
* "Any rows at all" is the wrong question: the backend is one shared
|
||||||
|
* process with one database, so a *single* row left by another spec
|
||||||
|
* file — `requested-badge` stages one album — satisfies that gate and
|
||||||
|
* this suite then draws a shelf page with no artist card on it and
|
||||||
|
* times out looking for one. It survives a suite run, so it is the
|
||||||
|
* second local `make e2e` that fails and the first that passes, which
|
||||||
|
* is the least useful order. CI never sees it: every run there is a
|
||||||
|
* fresh YJ_HOME.
|
||||||
|
*
|
||||||
|
* The inserts are `INSERT OR IGNORE` keyed on the MBID, so running
|
||||||
|
* this per test is idempotent, and adding seven low-popularity rows to
|
||||||
|
* a developer machine's real million-row catalog changes nothing the
|
||||||
|
* shelves show.
|
||||||
*/
|
*/
|
||||||
async function stageCatalogIfEmpty(app: Page): Promise<void> {
|
async function stageCatalog(app: Page): Promise<void> {
|
||||||
if ((await catalogRows(app)) > 0) return;
|
|
||||||
|
|
||||||
// The catalog stores an MBID as its 16 raw bytes and an entity type
|
// 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
|
// as a small integer, so a staged row has to be spelled the way the
|
||||||
@@ -185,15 +196,51 @@ async function stageCatalogIfEmpty(app: Page): Promise<void> {
|
|||||||
expect(result.status, `staging failed: ${result.body}`).toBe(200);
|
expect(result.status, `staging failed: ${result.body}`).toBe(200);
|
||||||
|
|
||||||
// …and `OR IGNORE` means a 200 is not a write. A CHECK the row
|
// …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.
|
// 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(
|
expect(
|
||||||
(JSON.parse(result.body) as { rowsAffected?: number }).rowsAffected,
|
(JSON.parse(result.body) as { rowsAffected?: number }).rowsAffected,
|
||||||
`staged nothing: ${result.body}`,
|
`staging error: ${result.body}`,
|
||||||
).toBe(1);
|
).toBeLessThanOrEqual(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(await catalogRows(app)).toBeGreaterThan(0);
|
// Every staged row is present, whoever put it there. An MBID that
|
||||||
|
// fails `CHECK(length(mbid) = 16)` is silently dropped by OR IGNORE,
|
||||||
|
// and this is where that shows up.
|
||||||
|
expect(await stagedRowCount(app), 'the staged catalog is incomplete')
|
||||||
|
.toBe(rows.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many of the staged fixture rows are in the catalog. */
|
||||||
|
async function stagedRowCount(app: Page): Promise<number> {
|
||||||
|
const result = await app.evaluate(async () => {
|
||||||
|
const res = await fetch('/__test/sql', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sql: `SELECT COUNT(*) AS n FROM explore_index
|
||||||
|
WHERE artist_name IN ('Staged Alpha', 'Staged Beta',
|
||||||
|
'Staged Gamma')`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status: res.status, body: await res.text() };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status, `count failed: ${result.body}`).toBe(200);
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result.body) as {
|
||||||
|
rows?: { n?: number }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
return parsed.rows?.[0]?.n ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
// 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
|
// size 1.4.10 names.
|
||||||
// app — the job indicator and the queue button among it — used to
|
//
|
||||||
// be behind `overflow: hidden` with no way to reach it.
|
// **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 });
|
await app.setViewportSize({ width: 320, height: 256 });
|
||||||
|
|
||||||
// A *gesture*, not `scrollLeft = 9999`: `overflow: hidden` still
|
const fit = await app.evaluate(() => {
|
||||||
// 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 se = document.scrollingElement!;
|
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
|
// And the vertical axis stays fixed, which is what keeps the
|
||||||
// transport where a desktop player's transport belongs.
|
// transport where a player's transport belongs.
|
||||||
expect(reach.top).toBe(0);
|
expect(fit.scrollHeight).toBeLessThanOrEqual(fit.clientHeight);
|
||||||
|
|
||||||
await app.evaluate(() => {
|
|
||||||
document.scrollingElement!.scrollLeft = 0;
|
|
||||||
});
|
|
||||||
await app.setViewportSize({ width: 1440, height: 900 });
|
await app.setViewportSize({ width: 1440, height: 900 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { test, expect } from '../support/fixtures.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Long-press is the touch route to a context menu (plan 016 B2 phase 3).
|
||||||
|
*
|
||||||
|
* The component tier proves the gesture in isolation, against markup it
|
||||||
|
* built itself. What it cannot prove is the half that made this one
|
||||||
|
* listener instead of six: that the synthetic event reaches the handler
|
||||||
|
* a *real* component bound — `track-list` delegates its `contextmenu`
|
||||||
|
* on the `lit-virtualizer` rather than binding one per row — and that
|
||||||
|
* the real `wa-popup` menu opens from it, which is a path with its own
|
||||||
|
* history of opening and then refusing to work (see
|
||||||
|
* `menu-keyboard.spec.ts`).
|
||||||
|
*
|
||||||
|
* The pointer events are dispatched rather than performed: this project
|
||||||
|
* runs Desktop Chrome and Desktop Safari, neither of which has touch,
|
||||||
|
* and a device tier does not exist. So this is honest about what it
|
||||||
|
* checks — the app's own listeners, on the app's own DOM, from the
|
||||||
|
* events a touch would produce — and not about a real finger.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A common small phone, as in `phone-shell.spec.ts`. */
|
||||||
|
const PHONE = { width: 390, height: 844 };
|
||||||
|
|
||||||
|
/** Comfortably past the module's 500ms hold. */
|
||||||
|
const HELD = 900;
|
||||||
|
|
||||||
|
type Page = import('@playwright/test').Page;
|
||||||
|
|
||||||
|
/** The track list's menu panel, or null while it is not rendered. */
|
||||||
|
const panel = (page: Page) =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const el = document
|
||||||
|
.querySelector('track-list')
|
||||||
|
?.shadowRoot?.querySelector('.context-menu-panel');
|
||||||
|
|
||||||
|
if (!el) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
role: el.getAttribute('role'),
|
||||||
|
label: el.getAttribute('aria-label'),
|
||||||
|
items: el.querySelectorAll('[role="menuitem"]').length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Press the first track row, optionally dragging partway through — the
|
||||||
|
* shape of a scroll that begins on a row, which must not open a menu.
|
||||||
|
*/
|
||||||
|
async function pressFirstRow(
|
||||||
|
page: Page,
|
||||||
|
opts: { driftY?: number } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
await page.evaluate((drift) => {
|
||||||
|
// `.track-row`, not `[role="row"]`: the column header is a row too,
|
||||||
|
// and it is the *first* one -- a press on it is correctly ignored,
|
||||||
|
// which reads exactly like the gesture not working.
|
||||||
|
const row = document
|
||||||
|
.querySelector('track-list')
|
||||||
|
?.shadowRoot?.querySelector('.track-row');
|
||||||
|
|
||||||
|
if (!row) throw new Error('no track row to press');
|
||||||
|
|
||||||
|
const box = row.getBoundingClientRect();
|
||||||
|
const x = Math.round(box.left + box.width / 2);
|
||||||
|
const y = Math.round(box.top + box.height / 2);
|
||||||
|
const send = (type: string, dy = 0) =>
|
||||||
|
row.dispatchEvent(
|
||||||
|
new PointerEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
cancelable: true,
|
||||||
|
pointerType: 'touch',
|
||||||
|
isPrimary: true,
|
||||||
|
clientX: x,
|
||||||
|
clientY: y + dy,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
send('pointerdown');
|
||||||
|
|
||||||
|
if (drift) send('pointermove', drift);
|
||||||
|
}, opts.driftY ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('long-press opens the track menu', () => {
|
||||||
|
test.beforeEach(async ({ app }) => {
|
||||||
|
await app.setViewportSize(PHONE);
|
||||||
|
await app.getByTestId('tab-tracks').click();
|
||||||
|
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||||||
|
'data-active-view',
|
||||||
|
'tracks',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ app }) => {
|
||||||
|
// Every other spec file runs against a desktop, and the viewport
|
||||||
|
// belongs to the shared context rather than to this file.
|
||||||
|
await app.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reaches the delegated handler and opens the real menu', async ({
|
||||||
|
app,
|
||||||
|
}) => {
|
||||||
|
await expect.poll(() => panel(app)).toBeNull();
|
||||||
|
|
||||||
|
await pressFirstRow(app);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => panel(app), { timeout: HELD + 2000 })
|
||||||
|
.toMatchObject({ role: 'menu', label: 'Track actions' });
|
||||||
|
|
||||||
|
// The same panel Shift+F10 opens, items and all -- not an empty
|
||||||
|
// popup that happened to become visible.
|
||||||
|
expect((await panel(app))?.items).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not open one for a press that turns into a scroll', async ({
|
||||||
|
app,
|
||||||
|
}) => {
|
||||||
|
await pressFirstRow(app, { driftY: 40 });
|
||||||
|
|
||||||
|
await app.waitForTimeout(HELD);
|
||||||
|
|
||||||
|
expect(await panel(app)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { test, expect } from '../support/fixtures.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The phone shell (plan 016 B2, phase 1).
|
||||||
|
*
|
||||||
|
* This is the tier that can actually answer the question. Wails v3's
|
||||||
|
* server mode serves the real frontend, so a Chromium at 390×844 is the
|
||||||
|
* same document an Android WebView renders — the only thing a device
|
||||||
|
* adds here is the WebView's own quirks, and CI runs the WebKit half
|
||||||
|
* for exactly that reason.
|
||||||
|
*
|
||||||
|
* The assertions are the three things B2 is *for*: the eleven-item
|
||||||
|
* sidebar is gone, the four destinations plan 016 committed to are
|
||||||
|
* reachable with a thumb, and nothing scrolls sideways. The last one is
|
||||||
|
* the one that hides: `overflow-x: auto` on `body` means a shell that
|
||||||
|
* does not fit produces a scrollbar rather than a broken layout, which
|
||||||
|
* looks survivable in a screenshot and is not.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A common small phone. Narrower than any device this is likely to meet. */
|
||||||
|
const PHONE = { width: 390, height: 844 };
|
||||||
|
|
||||||
|
/** The narrowest thing still sold, near enough. */
|
||||||
|
const SMALL_PHONE = { width: 360, height: 780 };
|
||||||
|
|
||||||
|
const horizontalOverflow = (page: import('@playwright/test').Page) =>
|
||||||
|
page.evaluate(() => ({
|
||||||
|
scrollWidth: document.body.scrollWidth,
|
||||||
|
clientWidth: document.body.clientWidth,
|
||||||
|
}));
|
||||||
|
|
||||||
|
test.describe('the shell on a phone', () => {
|
||||||
|
test.beforeEach(async ({ app }) => {
|
||||||
|
await app.setViewportSize(PHONE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replaces the sidebar with a bottom tab bar', async ({ app }) => {
|
||||||
|
await expect(app.locator('div.sidebar')).toBeHidden();
|
||||||
|
|
||||||
|
const nav = app.locator('bottom-nav');
|
||||||
|
|
||||||
|
await expect(nav).toBeVisible();
|
||||||
|
|
||||||
|
// Four tabs and a way to everything else, which is the shape the
|
||||||
|
// plan argues for: a tab bar is 3-5 items before the targets stop
|
||||||
|
// being thumb-sized.
|
||||||
|
for (const id of ['home', 'albums', 'tracks', 'playlists', 'more']) {
|
||||||
|
await expect(app.getByTestId(`tab-${id}`)).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('navigates from a tab', async ({ app }) => {
|
||||||
|
await app.getByTestId('tab-albums').click();
|
||||||
|
|
||||||
|
await expect(app.getByTestId('main-content'))
|
||||||
|
.toHaveAttribute('data-active-view', 'albums');
|
||||||
|
|
||||||
|
await app.getByTestId('tab-home').click();
|
||||||
|
|
||||||
|
await expect(app.getByTestId('main-content'))
|
||||||
|
.toHaveAttribute('data-active-view', 'home');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reaches the views with no tab through the drawer', async ({ app }) => {
|
||||||
|
await app.getByTestId('tab-more').click();
|
||||||
|
|
||||||
|
// Scoped to the drawer: the desktop sidebar is still in the DOM
|
||||||
|
// (hidden by the media query, not removed), so an unscoped testid
|
||||||
|
// matches two elements and Playwright's strict mode refuses --
|
||||||
|
// which is the right complaint, since the two really are different
|
||||||
|
// buttons.
|
||||||
|
//
|
||||||
|
// The drawer holds the *same* sidebar the desktop uses, so Settings
|
||||||
|
// -- which a phone still needs occasionally -- is reachable without
|
||||||
|
// a second list of destinations to keep in step.
|
||||||
|
const settings = app
|
||||||
|
.getByTestId('nav-drawer')
|
||||||
|
.getByTestId('nav-settings');
|
||||||
|
|
||||||
|
await expect(settings).toBeVisible();
|
||||||
|
await settings.click();
|
||||||
|
|
||||||
|
await expect(app.getByTestId('main-content'))
|
||||||
|
.toHaveAttribute('data-active-view', 'settings');
|
||||||
|
|
||||||
|
// And the drawer gets out of the way once it has done its job.
|
||||||
|
await expect(app.getByTestId('nav-drawer')).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('has a named drawer', async ({ app }) => {
|
||||||
|
await app.getByTestId('tab-more').click();
|
||||||
|
|
||||||
|
// The a11y snapshot never prints a dialog's name, so this asks for
|
||||||
|
// the role and the name together -- which is the check that caught
|
||||||
|
// eleven unnamed dialogs.
|
||||||
|
await expect(
|
||||||
|
app.getByRole('dialog', { name: 'All views' }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const vp of [PHONE, SMALL_PHONE]) {
|
||||||
|
test(`does not scroll sideways at ${vp.width}×${vp.height}`, async ({ app }) => {
|
||||||
|
await app.setViewportSize(vp);
|
||||||
|
await app.getByTestId('tab-tracks').click();
|
||||||
|
await expect(app.getByTestId('main-content'))
|
||||||
|
.toHaveAttribute('data-active-view', 'tracks');
|
||||||
|
|
||||||
|
const { scrollWidth, clientWidth } = await horizontalOverflow(app);
|
||||||
|
|
||||||
|
expect(scrollWidth, `body overflows by ${scrollWidth - clientWidth}px`)
|
||||||
|
.toBeLessThanOrEqual(clientWidth);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('opens the full-screen now playing, and comes back', async ({ app }) => {
|
||||||
|
// Something has to be playing for the mini player to be a way in.
|
||||||
|
await app.getByTestId('tab-tracks').click();
|
||||||
|
await expect(app.getByTestId('main-content'))
|
||||||
|
.toHaveAttribute('data-active-view', 'tracks');
|
||||||
|
|
||||||
|
await app.locator('track-list .track-row').first().dblclick();
|
||||||
|
await expect(app.getByTestId('now-playing-title')).not.toBeEmpty();
|
||||||
|
|
||||||
|
await app.getByTestId('open-now-playing').click();
|
||||||
|
|
||||||
|
await expect(app.getByTestId('main-content'))
|
||||||
|
.toHaveAttribute('data-active-view', 'now-playing');
|
||||||
|
|
||||||
|
// The seek bar and volume that phase 1 took out of the bottom bar
|
||||||
|
// are here, and they are the *same* components -- this view
|
||||||
|
// composes the transport rather than reimplementing it.
|
||||||
|
await expect(app.locator('now-playing-view seek-bar')).toBeVisible();
|
||||||
|
await expect(app.locator('now-playing-view volume-control')).toBeVisible();
|
||||||
|
|
||||||
|
// Back goes where the user came from, through the nav stack.
|
||||||
|
await app.getByTestId('npv-back').click();
|
||||||
|
await expect(app.getByTestId('main-content'))
|
||||||
|
.toHaveAttribute('data-active-view', 'tracks');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offers no way in on a desktop, where the bar is whole', async ({ app }) => {
|
||||||
|
await app.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
|
||||||
|
// The button exists in the markup at every size; CSS decides. If
|
||||||
|
// this becomes visible on a desktop it is a 48px hit target over
|
||||||
|
// the cover art, swallowing the clicks that open the preview.
|
||||||
|
await expect(app.getByTestId('open-now-playing')).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the transport, minus what a thumb cannot use', async ({ app }) => {
|
||||||
|
// The player bar stays: this is a music player, and what is playing
|
||||||
|
// has to be visible and pausable from every view.
|
||||||
|
await expect(app.locator('audio-player')).toBeVisible();
|
||||||
|
await expect(app.locator('now-playing')).toBeVisible();
|
||||||
|
|
||||||
|
// Volume is the hardware keys' job on a phone, and a 4px seek bar
|
||||||
|
// is not a thumb target -- both belong to a later phase's
|
||||||
|
// full-screen now-playing view.
|
||||||
|
await expect(app.locator('audio-player volume-control')).toBeHidden();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('the desktop shell is unchanged', () => {
|
||||||
|
test('keeps the sidebar and hides the tab bar', async ({ app }) => {
|
||||||
|
await app.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
|
||||||
|
await expect(app.locator('div.sidebar')).toBeVisible();
|
||||||
|
await expect(app.locator('bottom-nav')).toBeHidden();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,35 @@
|
|||||||
// @ts-ignore: Unused imports
|
// @ts-ignore: Unused imports
|
||||||
import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
|
import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore: Unused imports
|
||||||
|
import * as $models from "./models.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CheckStorageAccess asks the filesystem rather than the permission
|
||||||
|
* system.
|
||||||
|
*
|
||||||
|
* On Android this app holds MANAGE_EXTERNAL_STORAGE, which the user
|
||||||
|
* grants on a Settings screen rather than in a dialog — so it can be
|
||||||
|
* refused, revoked later, or simply never answered, and the permission
|
||||||
|
* API is one more thing that can disagree with reality. Reading the
|
||||||
|
* directory is the question the library scanner will actually ask, so
|
||||||
|
* it is the one worth answering.
|
||||||
|
*
|
||||||
|
* It is deliberately not an error return: "we cannot read your music
|
||||||
|
* yet" is a state the UI renders, not a failure of the call.
|
||||||
|
*/
|
||||||
|
export function CheckStorageAccess(): $CancellablePromise<$models.StorageAccess> {
|
||||||
|
return $Call.ByID(1661980006);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DefaultBrowseRoot is where a folder picker should open.
|
||||||
|
*/
|
||||||
|
export function DefaultBrowseRoot(): $CancellablePromise<string> {
|
||||||
|
return $Call.ByID(497852148);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DirectoryPicker opens a directory selection dialog.
|
* DirectoryPicker opens a directory selection dialog.
|
||||||
*
|
*
|
||||||
@@ -20,6 +49,22 @@ export function DirectoryPicker(): $CancellablePromise<string> {
|
|||||||
return $Call.ByID(3245034282);
|
return $Call.ByID(3245034282);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HasNativeDirectoryPicker reports whether this platform can open a
|
||||||
|
* directory dialog at all.
|
||||||
|
*
|
||||||
|
* It is asked of the backend rather than tested in the frontend with
|
||||||
|
* `System.IsAndroid()`, for three reasons. The dialog *is* backend code
|
||||||
|
* — `DirectoryPicker` above — so this is the same package saying what
|
||||||
|
* it can do. It answers for iOS too without the frontend enumerating
|
||||||
|
* platforms. And it makes the frontend's fallback testable through the
|
||||||
|
* ordinary transport fake instead of a module mock of the Wails
|
||||||
|
* runtime, whose platform helpers read build constants.
|
||||||
|
*/
|
||||||
|
export function HasNativeDirectoryPicker(): $CancellablePromise<boolean> {
|
||||||
|
return $Call.ByID(1028901937);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ImageFilePicker opens a file selection dialog filtered to image
|
* ImageFilePicker opens a file selection dialog filtered to image
|
||||||
* files (JPEG, PNG). Returns the selected file path, or empty
|
* files (JPEG, PNG). Returns the selected file path, or empty
|
||||||
@@ -29,6 +74,30 @@ export function ImageFilePicker(): $CancellablePromise<string> {
|
|||||||
return $Call.ByID(3408786006);
|
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
|
* PlaylistFilePicker opens a file selection dialog filtered
|
||||||
* to M3U/M3U8 playlist files. Multiple files may be selected.
|
* to M3U/M3U8 playlist files. Multiple files may be selected.
|
||||||
|
|||||||
@@ -5,3 +5,9 @@ import * as FrontendUtil from "./frontendutil.js";
|
|||||||
export {
|
export {
|
||||||
FrontendUtil
|
FrontendUtil
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type {
|
||||||
|
DirEntry,
|
||||||
|
DirListing,
|
||||||
|
StorageAccess
|
||||||
|
} from "./models.js";
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DirEntry is one selectable directory in a listing.
|
||||||
|
*/
|
||||||
|
export interface DirEntry {
|
||||||
|
"name": string;
|
||||||
|
"path": string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DirListing is one level of the filesystem, as a folder picker needs
|
||||||
|
* it: where we are, what is above, and the directories below.
|
||||||
|
*
|
||||||
|
* Parent is empty at a root, which is what tells the UI not to draw an
|
||||||
|
* "up" affordance rather than having it compute that from the path
|
||||||
|
* separator.
|
||||||
|
*/
|
||||||
|
export interface DirListing {
|
||||||
|
"path": string;
|
||||||
|
"parent": string;
|
||||||
|
"entries": DirEntry[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StorageAccess reports whether the app can actually read the place the
|
||||||
|
* user's music lives.
|
||||||
|
*/
|
||||||
|
export interface StorageAccess {
|
||||||
|
"root": string;
|
||||||
|
"readable": boolean;
|
||||||
|
"reason": string;
|
||||||
|
}
|
||||||
@@ -41,6 +41,18 @@ body {
|
|||||||
overflow-y: hidden;
|
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 {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
/* I want to set paragraph margins myself */
|
/* I want to set paragraph margins myself */
|
||||||
@@ -130,6 +142,8 @@ body div.sidebar {
|
|||||||
contain: layout style paint;
|
contain: layout style paint;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.bottom-bar {
|
.bottom-bar {
|
||||||
grid-area: bottom-bar;
|
grid-area: bottom-bar;
|
||||||
padding: 0.25em;
|
padding: 0.25em;
|
||||||
@@ -241,3 +255,110 @@ body div.sidebar {
|
|||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
contain: strict !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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,13 @@
|
|||||||
<wa-icon name="list"></wa-icon>
|
<wa-icon name="list"></wa-icon>
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
|
<!-- The phone's primary navigation, hidden above 600px by
|
||||||
|
index.css. Eager rather than a chunk, for the reason
|
||||||
|
notification-host is: it is the only way to move around the
|
||||||
|
app on a phone. After the footer, because that is where it
|
||||||
|
renders -- the tab bar sits below the transport, and DOM order
|
||||||
|
is what a screen reader and the tab sequence follow. -->
|
||||||
|
<bottom-nav></bottom-nav>
|
||||||
<first-run-wizard></first-run-wizard>
|
<first-run-wizard></first-run-wizard>
|
||||||
<notification-host></notification-host>
|
<notification-host></notification-host>
|
||||||
<shortcuts-overlay></shortcuts-overlay>
|
<shortcuts-overlay></shortcuts-overlay>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import '@components/audio-player/audio-player.ts';
|
|||||||
import '@components/track-list/track-list.ts';
|
import '@components/track-list/track-list.ts';
|
||||||
import '@components/now-playing/now-playing.ts';
|
import '@components/now-playing/now-playing.ts';
|
||||||
import '@components/sidebar/app-sidebar.ts';
|
import '@components/sidebar/app-sidebar.ts';
|
||||||
|
import '@components/bottom-nav/bottom-nav.ts';
|
||||||
import '@components/queue-panel/queue-panel.ts';
|
import '@components/queue-panel/queue-panel.ts';
|
||||||
import '@components/search-bar/search-bar.ts';
|
import '@components/search-bar/search-bar.ts';
|
||||||
import '@components/library-filter/library-filter.ts';
|
import '@components/library-filter/library-filter.ts';
|
||||||
@@ -49,6 +50,7 @@ import '@store/theme-store';
|
|||||||
// registers the document keydown listener for global shortcuts.
|
// registers the document keydown listener for global shortcuts.
|
||||||
import './src/services/keyboard-shortcut-service';
|
import './src/services/keyboard-shortcut-service';
|
||||||
import { activateView, deactivateView } from '@utils/view-lifecycle';
|
import { activateView, deactivateView } from '@utils/view-lifecycle';
|
||||||
|
import { installLongPressContextMenu } from '@utils/long-press';
|
||||||
import {
|
import {
|
||||||
hasTrackPayload,
|
hasTrackPayload,
|
||||||
getDragPayload,
|
getDragPayload,
|
||||||
@@ -63,6 +65,11 @@ setBasePath('/dist/webawesome');
|
|||||||
// the session.
|
// the session.
|
||||||
registerBundledIcons();
|
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
|
// View caching navigation system
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -126,6 +133,11 @@ const DETAIL_LOADERS: Record<string, () => Promise<unknown>> = {
|
|||||||
import('@components/explore-artist-details/explore-artist-details.js'),
|
import('@components/explore-artist-details/explore-artist-details.js'),
|
||||||
'explore-album-details': () =>
|
'explore-album-details': () =>
|
||||||
import('@components/explore-album-details/explore-album-details.js'),
|
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
|
// Opened from a menu rather than by navigating, so they have no entry
|
||||||
@@ -148,10 +160,6 @@ const viewCache = new Map<string, HTMLElement>();
|
|||||||
let currentViewEl: HTMLElement | null = null;
|
let currentViewEl: HTMLElement | null = null;
|
||||||
let currentDetailEl: 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');
|
const mainContent = document.getElementById('main-content');
|
||||||
|
|
||||||
@@ -184,6 +192,71 @@ document.addEventListener('navigate', (e: Event) => {
|
|||||||
void handleNavigate((e as CustomEvent).detail);
|
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(
|
async function handleNavigate(
|
||||||
detail: { view: string; [key: string]: any },
|
detail: { view: string; [key: string]: any },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -193,6 +266,8 @@ async function handleNavigate(
|
|||||||
|
|
||||||
const seq = ++navSeq;
|
const seq = ++navSeq;
|
||||||
|
|
||||||
|
if (!detail._isBack) recordNavigation(detail);
|
||||||
|
|
||||||
// Bookkeeping stays synchronous with the click: the search box's
|
// Bookkeeping stays synchronous with the click: the search box's
|
||||||
// scope and the active-view attribute describe the navigation that
|
// scope and the active-view attribute describe the navigation that
|
||||||
// was *asked for*, and are what the rest of the app and the e2e
|
// was *asked for*, and are what the rest of the app and the e2e
|
||||||
@@ -206,9 +281,6 @@ async function handleNavigate(
|
|||||||
|
|
||||||
// --- Primary (cacheable) views ----------------------------------------
|
// --- Primary (cacheable) views ----------------------------------------
|
||||||
if (view in VIEW_TAGS) {
|
if (view in VIEW_TAGS) {
|
||||||
// Navigating to a primary view clears the history stack.
|
|
||||||
navStack.length = 0;
|
|
||||||
|
|
||||||
// Remove any active detail view first
|
// Remove any active detail view first
|
||||||
if (currentDetailEl) {
|
if (currentDetailEl) {
|
||||||
deactivateView(currentDetailEl);
|
deactivateView(currentDetailEl);
|
||||||
@@ -241,7 +313,6 @@ async function handleNavigate(
|
|||||||
// the way out. Either way this is the call that starts it.
|
// the way out. Either way this is the call that starts it.
|
||||||
activateView(target);
|
activateView(target);
|
||||||
currentViewEl = target;
|
currentViewEl = target;
|
||||||
currentNavDetail = { view };
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -250,12 +321,6 @@ async function handleNavigate(
|
|||||||
if (seq !== navSeq) return;
|
if (seq !== navSeq) return;
|
||||||
|
|
||||||
// --- Detail (ephemeral) views -----------------------------------------
|
// --- 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
|
// Hide the current primary view
|
||||||
if (currentViewEl) {
|
if (currentViewEl) {
|
||||||
currentViewEl.classList.add('view-hidden');
|
currentViewEl.classList.add('view-hidden');
|
||||||
@@ -268,8 +333,6 @@ async function handleNavigate(
|
|||||||
currentDetailEl = null;
|
currentDetailEl = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentNavDetail = { ...detail };
|
|
||||||
|
|
||||||
switch (view) {
|
switch (view) {
|
||||||
case 'artist-details': {
|
case 'artist-details': {
|
||||||
const { artistId, artistName } = detail;
|
const { artistId, artistName } = detail;
|
||||||
@@ -304,6 +367,13 @@ async function handleNavigate(
|
|||||||
currentDetailEl = spEl;
|
currentDetailEl = spEl;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'now-playing': {
|
||||||
|
const npEl = document.createElement('now-playing-view');
|
||||||
|
|
||||||
|
mainContent.appendChild(npEl);
|
||||||
|
currentDetailEl = npEl;
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'genre-details': {
|
case 'genre-details': {
|
||||||
const { genreName } = detail;
|
const { genreName } = detail;
|
||||||
const genreEl = document.createElement('genre-details');
|
const genreEl = document.createElement('genre-details');
|
||||||
@@ -406,16 +476,16 @@ function schedule(fn: () => void): void {
|
|||||||
setTimeout(fn, 200);
|
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', () => {
|
document.addEventListener('navigate-back', () => {
|
||||||
const prev = navStack.pop();
|
if (pushedEntries > 0) history.back();
|
||||||
if (prev) {
|
|
||||||
document.dispatchEvent(new CustomEvent('navigate', {
|
|
||||||
bubbles: true,
|
|
||||||
composed: true,
|
|
||||||
detail: { ...prev, _isBack: true },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Navigate to the user's configured launch page. Falls back to 'home'
|
// Navigate to the user's configured launch page. Falls back to 'home'
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/></svg>
|
||||||
|
After Width: | Height: | Size: 608 B |
@@ -32,6 +32,23 @@ export class AudioPlayer extends LitElement {
|
|||||||
flex: 1;
|
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() {
|
override render() {
|
||||||
|
|||||||
@@ -27,6 +27,18 @@ export class SeekBar extends LitElement {
|
|||||||
private showRemaining: boolean = true;
|
private showRemaining: boolean = true;
|
||||||
|
|
||||||
static override styles = [designTokens, waSliderLabel, css`
|
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 {
|
wa-slider {
|
||||||
--track-size: 6px;
|
--track-size: 6px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { LitElement, html, css, nothing } from 'lit';
|
||||||
|
import { customElement, state, query } from 'lit/decorators.js';
|
||||||
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
|
import '@awesome.me/webawesome/dist/components/drawer/drawer.js';
|
||||||
|
import type WaDrawer from '@awesome.me/webawesome/dist/components/drawer/drawer.js';
|
||||||
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
|
import '../sidebar/app-sidebar.js';
|
||||||
|
import { nameDialog } from '@utils/name-dialog';
|
||||||
|
|
||||||
|
type View = 'home' | 'albums' | 'tracks' | 'playlists';
|
||||||
|
|
||||||
|
interface Tab {
|
||||||
|
id: View;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The phone's primary navigation: a bottom tab bar, shown only below
|
||||||
|
* the phone breakpoint (index.css owns that; this element is
|
||||||
|
* `display: none` above it).
|
||||||
|
*
|
||||||
|
* **Four destinations and a way to everything else.** A tab bar is
|
||||||
|
* three to five items before the targets stop being thumb-sized —
|
||||||
|
* 360 px over eleven sidebar entries is 32 px each — so the four here
|
||||||
|
* are the ones plan 016's subset says a phone is *for*, and "More"
|
||||||
|
* opens the existing `<app-sidebar>` in a drawer. That is deliberately
|
||||||
|
* a reuse rather than a second nav: two lists of destinations is two
|
||||||
|
* places to add the next view to, and the sidebar already carries the
|
||||||
|
* drag-to-navigate behaviour, the active state and the labels.
|
||||||
|
*
|
||||||
|
* It emits the same bubbling, composed `navigate` event the sidebar
|
||||||
|
* does, so `index.ts` needs no knowledge of it, and it listens for that
|
||||||
|
* event globally for the same reason the sidebar does: a navigation it
|
||||||
|
* did not send (a card click, a detail view, the drawer) still has to
|
||||||
|
* move the highlight.
|
||||||
|
*/
|
||||||
|
@customElement('bottom-nav')
|
||||||
|
export class BottomNav extends LitElement {
|
||||||
|
static override styles = [designTokens, css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
background-color: var(--yj-bg-elevated, #343a40);
|
||||||
|
border-top: 1px solid var(--yj-border, #495057);
|
||||||
|
/* The home indicator on a gesture-navigation phone sits
|
||||||
|
under the last few pixels of the viewport, so the bar
|
||||||
|
pads itself out of the way where the browser reports
|
||||||
|
one and by nothing where it does not. */
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
nav ul {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: 1fr;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
/* 48px is the smallest target this should ever be; the
|
||||||
|
label sits under the icon rather than beside it, which
|
||||||
|
is what keeps five of them legible at 360px. */
|
||||||
|
min-height: 48px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 4px 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--yj-text-secondary, #adb5bd);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--yj-font-size-xs, 0.7rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
button wa-icon {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.active {
|
||||||
|
color: var(--yj-accent, #ffd43b);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 2px solid var(--yj-accent, #ffd43b);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
/* A tab label is an aid, not the name: the button's own
|
||||||
|
accessible name comes from its text, and truncating it
|
||||||
|
visually does not change that. */
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
wa-drawer::part(body) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
app-sidebar {
|
||||||
|
/* The sidebar sizes itself inline and collapses to icons
|
||||||
|
below 900px, which is every phone. In the drawer there
|
||||||
|
is room for the labels, so it is told not to. */
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
`];
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private activeView = 'home';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the drawer has been asked for.
|
||||||
|
*
|
||||||
|
* The sidebar inside it is rendered only while this is true, and
|
||||||
|
* that is not an optimisation. `app-sidebar` carries a
|
||||||
|
* `data-testid` per destination, so a second copy standing by in
|
||||||
|
* the DOM makes every `nav-*` testid ambiguous **for the whole
|
||||||
|
* app** -- 30 existing specs failed with "strict mode violation:
|
||||||
|
* resolved to 2 elements" on a desktop viewport where this element
|
||||||
|
* is not even visible. A duplicate of a shared component is a
|
||||||
|
* duplicate of its handles.
|
||||||
|
*/
|
||||||
|
@state()
|
||||||
|
private drawerOpen = false;
|
||||||
|
|
||||||
|
@query('wa-drawer')
|
||||||
|
private drawer?: WaDrawer;
|
||||||
|
|
||||||
|
private static readonly TABS: Tab[] = [
|
||||||
|
{ id: 'home', label: 'Home', icon: 'house' },
|
||||||
|
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
|
||||||
|
{ id: 'tracks', label: 'Tracks', icon: 'music' },
|
||||||
|
{ id: 'playlists', label: 'Playlists', icon: 'list' },
|
||||||
|
];
|
||||||
|
|
||||||
|
override connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
document.addEventListener(
|
||||||
|
'navigate',
|
||||||
|
this.onGlobalNavigate as EventListener,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
override disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
document.removeEventListener(
|
||||||
|
'navigate',
|
||||||
|
this.onGlobalNavigate as EventListener,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
override updated() {
|
||||||
|
// Web Awesome renders its heading into its own shadow root and
|
||||||
|
// never points aria-labelledby at it, so the drawer would
|
||||||
|
// otherwise be announced unnamed -- the same fix, and the same
|
||||||
|
// reason, as every wa-dialog in the app. A drawer's shadow root
|
||||||
|
// has the same shape, so the helper needs no change.
|
||||||
|
nameDialog(this.drawer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onGlobalNavigate = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent<{ view?: string }>).detail;
|
||||||
|
|
||||||
|
if (detail?.view) this.activeView = detail.view;
|
||||||
|
|
||||||
|
// A navigation from inside the drawer is the drawer's job done.
|
||||||
|
this.drawerOpen = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
private openDrawer = () => {
|
||||||
|
this.drawerOpen = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web Awesome closes itself on Escape and on a click outside, and
|
||||||
|
* tells us afterwards rather than asking -- so the flag follows the
|
||||||
|
* element, or the next `open` would be a no-op against a drawer
|
||||||
|
* that thinks it is already open.
|
||||||
|
*/
|
||||||
|
private onDrawerHide = () => {
|
||||||
|
this.drawerOpen = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
private navigate(view: View) {
|
||||||
|
this.dispatchEvent(new CustomEvent('navigate', {
|
||||||
|
detail: { view },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html`
|
||||||
|
<nav aria-label="Primary">
|
||||||
|
<ul>
|
||||||
|
${BottomNav.TABS.map((tab) => html`
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class=${this.activeView === tab.id ? 'active' : ''}
|
||||||
|
data-testid="tab-${tab.id}"
|
||||||
|
aria-current=${this.activeView === tab.id
|
||||||
|
? 'page'
|
||||||
|
: 'false'}
|
||||||
|
@click=${() => this.navigate(tab.id)}
|
||||||
|
>
|
||||||
|
<wa-icon name=${tab.icon}></wa-icon>
|
||||||
|
<span class="label">${tab.label}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
`)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="tab-more"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
@click=${this.openDrawer}
|
||||||
|
>
|
||||||
|
<wa-icon name="bars"></wa-icon>
|
||||||
|
<span class="label">More</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<wa-drawer
|
||||||
|
placement="start"
|
||||||
|
label="All views"
|
||||||
|
data-testid="nav-drawer"
|
||||||
|
?open=${this.drawerOpen}
|
||||||
|
@wa-after-hide=${this.onDrawerHide}
|
||||||
|
>
|
||||||
|
${this.drawerOpen
|
||||||
|
? html`<app-sidebar expanded></app-sidebar>`
|
||||||
|
: nothing}
|
||||||
|
</wa-drawer>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'bottom-nav': BottomNav;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
SetQueueFallback,
|
SetQueueFallback,
|
||||||
} from '@go/config/config.js';
|
} from '@go/config/config.js';
|
||||||
import { GetIndexStatus } from '@go/explore/service.js';
|
import { GetIndexStatus } from '@go/explore/service.js';
|
||||||
import { DirectoryPicker } from '@go/frontendutil/frontendutil.js';
|
|
||||||
import { notificationStore } from '@store/notification-store';
|
import { notificationStore } from '@store/notification-store';
|
||||||
import { describeError, explainError } from '@utils/describe-error';
|
import { describeError, explainError } from '@utils/describe-error';
|
||||||
import type * as library from '@go/library/models.js';
|
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 { shortcutsStore } from '../../store/shortcuts-store';
|
||||||
import { ShortcutsController } from '../../store/controllers/shortcuts-controller';
|
import { ShortcutsController } from '../../store/controllers/shortcuts-controller';
|
||||||
import { list } from '@utils/binding';
|
import { list } from '@utils/binding';
|
||||||
|
import { pickDirectory } from '../../utils/pick-directory';
|
||||||
|
|
||||||
const SCROLL_STORAGE_KEY = 'yj-now-playing-scroll-mode';
|
const SCROLL_STORAGE_KEY = 'yj-now-playing-scroll-mode';
|
||||||
const SCROLL_CHANGE_EVENT = 'yj-scroll-mode-changed';
|
const SCROLL_CHANGE_EVENT = 'yj-scroll-mode-changed';
|
||||||
@@ -916,7 +916,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
let dir = '';
|
let dir = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
dir = await DirectoryPicker();
|
dir = (await pickDirectory()) ?? '';
|
||||||
|
|
||||||
if (!dir) return;
|
if (!dir) return;
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import type {
|
|||||||
ProviderField,
|
ProviderField,
|
||||||
} from '@store/download-store';
|
} from '@store/download-store';
|
||||||
import { downloadStore } 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 { GetDownloadPreferences, SetDownloadPreferences } from '@go/config/config.js';
|
||||||
import { SetPreferences } from '@go/download/service.js';
|
import { SetPreferences } from '@go/download/service.js';
|
||||||
import type * as download from '@go/download/models.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 { describeError, explainError } from '@utils/describe-error';
|
||||||
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
|
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
|
||||||
import './config-section';
|
import './config-section';
|
||||||
|
import { pickDirectory } from '../../utils/pick-directory';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allowed audio formats for auto-download, mirrored from
|
* Allowed audio formats for auto-download, mirrored from
|
||||||
@@ -580,7 +580,7 @@ export class DownloadClients extends LitElement {
|
|||||||
|
|
||||||
private browseForFolder = async (field: ProviderField) => {
|
private browseForFolder = async (field: ProviderField) => {
|
||||||
try {
|
try {
|
||||||
const dir = await DirectoryPicker();
|
const dir = await pickDirectory();
|
||||||
|
|
||||||
if (dir) {
|
if (dir) {
|
||||||
this.draft = { ...this.draft, [field.key]: dir };
|
this.draft = { ...this.draft, [field.key]: dir };
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import {
|
|||||||
AddLibrary,
|
AddLibrary,
|
||||||
GetAllLibrariesWithTrackCounts,
|
GetAllLibrariesWithTrackCounts,
|
||||||
} from '@go/library/library.js';
|
} from '@go/library/library.js';
|
||||||
import { DirectoryPicker } from '@go/frontendutil/frontendutil.js';
|
|
||||||
import { describeError, explainError } from '@utils/describe-error';
|
import { describeError, explainError } from '@utils/describe-error';
|
||||||
import { nameDialogsIn } from '@utils/name-dialog';
|
import { nameDialogsIn } from '@utils/name-dialog';
|
||||||
|
import { pickDirectory } from '../../utils/pick-directory';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* First-run setup wizard.
|
* First-run setup wizard.
|
||||||
@@ -243,7 +243,7 @@ export class FirstRunWizard extends LitElement {
|
|||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const dir = await DirectoryPicker();
|
const dir = await pickDirectory();
|
||||||
|
|
||||||
if (dir) this.selectedDirectory = dir;
|
if (dir) this.selectedDirectory = dir;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
/**
|
||||||
|
* Choosing a directory, where the platform will not do it for us.
|
||||||
|
*
|
||||||
|
* Wails' file dialog can select directories on every desktop platform.
|
||||||
|
* On Android it returns an error, because the Storage Access Framework
|
||||||
|
* yields tree URIs rather than filesystem paths — and a path is what
|
||||||
|
* this app's whole library model is keyed on. So the app browses the
|
||||||
|
* filesystem itself, through `ListDirectories`, which it can do because
|
||||||
|
* it holds all-files access.
|
||||||
|
*
|
||||||
|
* Deliberately not a general file browser: it lists directories only,
|
||||||
|
* because the thing being chosen is a library root.
|
||||||
|
*
|
||||||
|
* The shape is `confirm-dialog`'s — a promise-returning `choose()` on a
|
||||||
|
* `wa-dialog`, so callers `await` a path or `null` and there is no
|
||||||
|
* second dialog pattern in the codebase.
|
||||||
|
*/
|
||||||
|
import { LitElement, css, html, nothing } from 'lit';
|
||||||
|
import { customElement, query, state } from 'lit/decorators.js';
|
||||||
|
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DefaultBrowseRoot,
|
||||||
|
ListDirectories,
|
||||||
|
} from '@go/frontendutil/frontendutil.js';
|
||||||
|
|
||||||
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
|
import { srOnly } from '../../styles/sr-only.css';
|
||||||
|
import { describeError } from '../../utils/describe-error';
|
||||||
|
import { nameDialogsIn } from '../../utils/name-dialog';
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('folder-picker')
|
||||||
|
export class FolderPicker extends LitElement {
|
||||||
|
@query('wa-dialog') private dialog?: HTMLElement & { open: boolean };
|
||||||
|
|
||||||
|
@state() private path = '';
|
||||||
|
@state() private parent = '';
|
||||||
|
@state() private entries: Entry[] = [];
|
||||||
|
@state() private loading = false;
|
||||||
|
@state() private errorMessage = '';
|
||||||
|
@state() private isOpen = false;
|
||||||
|
|
||||||
|
private settle: ((path: string | null) => void) | null = null;
|
||||||
|
|
||||||
|
static override styles = [
|
||||||
|
designTokens,
|
||||||
|
srOnly,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
wa-dialog::part(dialog) {
|
||||||
|
background: var(--yj-bg-surface, #212529);
|
||||||
|
color: var(--yj-text-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current {
|
||||||
|
color: var(--yj-text-secondary, #b3b3b3);
|
||||||
|
font-size: var(--yj-text-sm, 0.8125rem);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
border: 1px solid var(--yj-border, #495057);
|
||||||
|
border-radius: 4px;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
max-height: 45vh;
|
||||||
|
min-height: 8em;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li button {
|
||||||
|
align-items: center;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
font: inherit;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
li button:hover,
|
||||||
|
li button:focus-visible {
|
||||||
|
background: var(--yj-bg-elevated, #343a40);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--yj-text-secondary, #b3b3b3);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--yj-error-text, #ff8787);
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button {
|
||||||
|
background: var(--yj-bg-elevated, #343a40);
|
||||||
|
border: 1px solid var(--yj-border, #495057);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
padding: 6px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button.primary {
|
||||||
|
background: var(--yj-accent, #ffd43b);
|
||||||
|
border-color: var(--yj-accent, #ffd43b);
|
||||||
|
color: var(--yj-accent-fg, #000);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Browse. Resolves with an absolute path, or null if cancelled. */
|
||||||
|
async choose(startAt?: string): Promise<string | null> {
|
||||||
|
this.settle?.(null);
|
||||||
|
this.settle = null;
|
||||||
|
|
||||||
|
let start = startAt ?? '';
|
||||||
|
|
||||||
|
if (!start) {
|
||||||
|
try {
|
||||||
|
start = await DefaultBrowseRoot();
|
||||||
|
} catch {
|
||||||
|
start = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isOpen = true;
|
||||||
|
await this.load(start);
|
||||||
|
await this.updateComplete;
|
||||||
|
|
||||||
|
if (this.dialog) this.dialog.open = true;
|
||||||
|
|
||||||
|
return new Promise<string | null>((resolve) => {
|
||||||
|
this.settle = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(path: string): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
this.errorMessage = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const listing = await ListDirectories(path);
|
||||||
|
|
||||||
|
this.path = listing.path;
|
||||||
|
this.parent = listing.parent;
|
||||||
|
this.entries = listing.entries ?? [];
|
||||||
|
} catch (err) {
|
||||||
|
// A directory that cannot be read is not a failed picker —
|
||||||
|
// stay where we are and say so, or the user is stranded
|
||||||
|
// with an empty dialog and no way back.
|
||||||
|
this.errorMessage = describeError(
|
||||||
|
err,
|
||||||
|
'That folder could not be opened.',
|
||||||
|
);
|
||||||
|
console.error('folder-picker: listing failed:', err);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private close(path: string | null): void {
|
||||||
|
const settle = this.settle;
|
||||||
|
|
||||||
|
this.settle = null;
|
||||||
|
|
||||||
|
if (this.dialog) this.dialog.open = false;
|
||||||
|
this.isOpen = false;
|
||||||
|
settle?.(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
override updated(): void {
|
||||||
|
nameDialogsIn(this.shadowRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
if (!this.isOpen) return nothing;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<wa-dialog
|
||||||
|
label="Choose a folder"
|
||||||
|
data-testid="folder-picker"
|
||||||
|
@wa-hide=${() => this.close(null)}
|
||||||
|
>
|
||||||
|
<p class="current" data-testid="folder-picker-path">
|
||||||
|
${this.path || '\u2026'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
${this.errorMessage
|
||||||
|
? html`<p class="error" role="alert">
|
||||||
|
${this.errorMessage}
|
||||||
|
</p>`
|
||||||
|
: nothing}
|
||||||
|
|
||||||
|
<ul data-testid="folder-picker-list">
|
||||||
|
${this.parent
|
||||||
|
? html`<li>
|
||||||
|
<button
|
||||||
|
@click=${() => void this.load(this.parent)}
|
||||||
|
data-testid="folder-picker-up"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">\u2191</span> Up one
|
||||||
|
level
|
||||||
|
</button>
|
||||||
|
</li>`
|
||||||
|
: nothing}
|
||||||
|
${this.entries.map(
|
||||||
|
(entry) => html`
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
@click=${() => void this.load(entry.path)}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">\u{1F4C1}</span>
|
||||||
|
${entry.name}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
`,
|
||||||
|
)}
|
||||||
|
${!this.loading && this.entries.length === 0
|
||||||
|
? html`<li class="empty">No folders here.</li>`
|
||||||
|
: nothing}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The live region is in the DOM before it has anything to
|
||||||
|
say, because most screen readers announce a change to a
|
||||||
|
region they are already watching and ignore one that
|
||||||
|
appears with its content already in it.
|
||||||
|
-->
|
||||||
|
<p class="sr-only" role="status" aria-live="polite">
|
||||||
|
${this.loading
|
||||||
|
? 'Loading folders'
|
||||||
|
: `${this.entries.length} folders in ${this.path}`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button @click=${() => this.close(null)}>Cancel</button>
|
||||||
|
<button
|
||||||
|
class="primary"
|
||||||
|
?disabled=${!this.path}
|
||||||
|
@click=${() => this.close(this.path)}
|
||||||
|
data-testid="folder-picker-select"
|
||||||
|
>
|
||||||
|
Use this folder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</wa-dialog>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'folder-picker': FolderPicker;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -149,6 +149,19 @@ export class JobIndicator extends LitElement {
|
|||||||
text-overflow: ellipsis;
|
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 {
|
.alert-dot {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import { LitElement, html, css, nothing } from 'lit';
|
||||||
|
import { customElement } from 'lit/decorators.js';
|
||||||
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
|
import '../audio-player/controls/player-controls';
|
||||||
|
import '../audio-player/seekbar/seek-bar';
|
||||||
|
import '../audio-player/volume-control/volume-control';
|
||||||
|
import {
|
||||||
|
artistLink,
|
||||||
|
albumLink,
|
||||||
|
exploreLinkStyles,
|
||||||
|
} from '@utils/explore-link';
|
||||||
|
import { PlayerController } from '@store/controllers/player-controller';
|
||||||
|
import { FavoritesController } from '@store/controllers/favorites-controller';
|
||||||
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
|
import { srOnly } from '../../styles/sr-only.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is playing, at the size a phone has room for (plan 016 B2,
|
||||||
|
* phase 2).
|
||||||
|
*
|
||||||
|
* Phase 1 took the seek bar and the volume out of the bottom bar,
|
||||||
|
* because 4px of height is not a thumb target and a phone's volume
|
||||||
|
* belongs to its hardware keys. This is where they went: the same
|
||||||
|
* `<seek-bar>`, `<player-controls>` and `<volume-control>` elements the
|
||||||
|
* desktop transport uses, given room. **Not copies of them** — a phone
|
||||||
|
* layout that reimplements the transport is a second transport to fix
|
||||||
|
* every bug in, and the seek bar in particular carries the
|
||||||
|
* interpolation rules that took a plan of their own to get right.
|
||||||
|
*
|
||||||
|
* It is a *detail* view rather than a primary one: it is somewhere you
|
||||||
|
* go and come back from, so `index.ts` pushes the current view onto the
|
||||||
|
* nav stack and Back pops it. That is also why it is not in the tab
|
||||||
|
* bar — a tab you cannot leave by pressing the same tab again is not a
|
||||||
|
* tab.
|
||||||
|
*/
|
||||||
|
@customElement('now-playing-view')
|
||||||
|
export class NowPlayingView extends LitElement {
|
||||||
|
private player = new PlayerController(this);
|
||||||
|
private favCtrl = new FavoritesController(this);
|
||||||
|
|
||||||
|
static override styles = [designTokens, srOnly, exploreLinkStyles, css`
|
||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.75em 1em 1.25em;
|
||||||
|
gap: 0.75em;
|
||||||
|
background-color: var(--yj-bg-surface, #212529);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5em;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--yj-text-primary, #f8f9fa);
|
||||||
|
/* 48px is the touch-target floor, and this is the control
|
||||||
|
that gets a user out of a full-screen view. */
|
||||||
|
min-width: 48px;
|
||||||
|
min-height: 48px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back:focus-visible {
|
||||||
|
outline: 2px solid var(--yj-accent, #ffd43b);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context {
|
||||||
|
font-size: var(--yj-font-size-xs, 0.75rem);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--yj-text-secondary, #adb5bd);
|
||||||
|
}
|
||||||
|
|
||||||
|
.art {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.art img,
|
||||||
|
.art .placeholder {
|
||||||
|
/* Square, and never taller than the room left over: the
|
||||||
|
art is the one thing here that would happily push the
|
||||||
|
transport off the bottom of a short phone. */
|
||||||
|
width: min(100%, 60vh);
|
||||||
|
aspect-ratio: 1;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 12px;
|
||||||
|
background-color: var(--yj-bg-elevated, #343a40);
|
||||||
|
}
|
||||||
|
|
||||||
|
.art .placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 3rem;
|
||||||
|
color: var(--yj-text-tertiary, #868e96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75em;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.names {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
/* Two lines, then an ellipsis. A marquee is the bottom
|
||||||
|
bar's answer to a 320px box; here there is room to wrap,
|
||||||
|
and wrapping does not move. */
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist,
|
||||||
|
.album {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--yj-text-secondary, #adb5bd);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--yj-text-secondary, #adb5bd);
|
||||||
|
min-width: 48px;
|
||||||
|
min-height: 48px;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite.on {
|
||||||
|
color: var(--yj-accent, #ffd43b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite:focus-visible {
|
||||||
|
outline: 2px solid var(--yj-accent, #ffd43b);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transport {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The seek bar is the reason this view exists. Its own
|
||||||
|
stylesheet thickens the track below the phone breakpoint --
|
||||||
|
the track size is set on the wa-slider inside its shadow
|
||||||
|
root, so a custom property set from here would not reach
|
||||||
|
it. */
|
||||||
|
seek-bar {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--yj-text-secondary, #adb5bd);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
`];
|
||||||
|
|
||||||
|
private back() {
|
||||||
|
this.dispatchEvent(new CustomEvent('navigate-back', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the queue.
|
||||||
|
*
|
||||||
|
* This view hides the bottom bar (index.css), and the bar is where
|
||||||
|
* the queue button lives -- so without this, going full-screen
|
||||||
|
* would take the queue away. It toggles the same `open` attribute
|
||||||
|
* `index.ts` does, because the panel's state is an attribute on one
|
||||||
|
* element and a second mechanism for it is a second thing to keep
|
||||||
|
* in step.
|
||||||
|
*/
|
||||||
|
private openQueue() {
|
||||||
|
document.getElementById('queue-panel')?.setAttribute('open', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private toggleFavorite() {
|
||||||
|
const path = this.player.currentTrack?.filePath;
|
||||||
|
|
||||||
|
if (path) void this.favCtrl.toggleFavorite(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
const track = this.player.currentTrack;
|
||||||
|
|
||||||
|
if (!track) {
|
||||||
|
return html`
|
||||||
|
${this.renderHeader()}
|
||||||
|
<p class="empty" data-testid="npv-empty">
|
||||||
|
Nothing is playing.
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const favorited = this.favCtrl.isFavorited(track.filePath);
|
||||||
|
// The largest kept tier, which is what `saveCoverArt` records as
|
||||||
|
// the path -- there is no full-resolution original to reach for.
|
||||||
|
const art = track.coverArtLarge || track.coverArt;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
${this.renderHeader()}
|
||||||
|
|
||||||
|
<div class="art">
|
||||||
|
${art
|
||||||
|
? html`<img
|
||||||
|
src=${art}
|
||||||
|
alt=""
|
||||||
|
decoding="async"
|
||||||
|
data-testid="npv-art"
|
||||||
|
/>`
|
||||||
|
: html`<div class="placeholder" aria-hidden="true">
|
||||||
|
<wa-icon name="compact-disc"></wa-icon>
|
||||||
|
</div>`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="meta">
|
||||||
|
<div class="names">
|
||||||
|
<h2 class="title" data-testid="npv-title">
|
||||||
|
${track.title || track.fileName}
|
||||||
|
</h2>
|
||||||
|
<p class="artist">
|
||||||
|
${artistLink(track.artist, track.artistMbid)}
|
||||||
|
</p>
|
||||||
|
${track.album
|
||||||
|
? html`<p class="album">
|
||||||
|
${albumLink(
|
||||||
|
track.album,
|
||||||
|
track.releaseGroupMbid,
|
||||||
|
undefined,
|
||||||
|
track.artist,
|
||||||
|
)}
|
||||||
|
</p>`
|
||||||
|
: nothing}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="favorite ${favorited ? 'on' : ''}"
|
||||||
|
data-testid="npv-favorite"
|
||||||
|
aria-pressed=${favorited ? 'true' : 'false'}
|
||||||
|
aria-label=${favorited
|
||||||
|
? `Remove ${track.title} from ${this.favCtrl.playlistName}`
|
||||||
|
: `Add ${track.title} to ${this.favCtrl.playlistName}`}
|
||||||
|
@click=${this.toggleFavorite}
|
||||||
|
>
|
||||||
|
<wa-icon name=${this.favCtrl.iconName}></wa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="transport">
|
||||||
|
<seek-bar></seek-bar>
|
||||||
|
<player-controls></player-controls>
|
||||||
|
<volume-control></volume-control>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderHeader() {
|
||||||
|
return html`
|
||||||
|
<header>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="back"
|
||||||
|
data-testid="npv-back"
|
||||||
|
aria-label="Back"
|
||||||
|
@click=${this.back}
|
||||||
|
>
|
||||||
|
<wa-icon name="chevron-down"></wa-icon>
|
||||||
|
</button>
|
||||||
|
<span class="context">Now playing</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="back"
|
||||||
|
data-testid="npv-queue"
|
||||||
|
aria-label="Show the queue"
|
||||||
|
@click=${this.openQueue}
|
||||||
|
>
|
||||||
|
<wa-icon name="list"></wa-icon>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'now-playing-view': NowPlayingView;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -147,6 +147,41 @@ export class NowPlaying extends LitElement {
|
|||||||
position: relative;
|
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 {
|
.cover-preview-panel {
|
||||||
width: 500px;
|
width: 500px;
|
||||||
height: 500px;
|
height: 500px;
|
||||||
@@ -392,6 +427,13 @@ export class NowPlaying extends LitElement {
|
|||||||
<div class="sr-only" role="status" aria-live="polite">${announcement}</div>
|
<div class="sr-only" role="status" aria-live="polite">${announcement}</div>
|
||||||
<div class="now-playing">
|
<div class="now-playing">
|
||||||
<div class="cover-art-wrapper">
|
<div class="cover-art-wrapper">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="expand"
|
||||||
|
data-testid="open-now-playing"
|
||||||
|
aria-label="Open now playing"
|
||||||
|
@click=${this.openNowPlaying}
|
||||||
|
></button>
|
||||||
<div
|
<div
|
||||||
class="cover-art"
|
class="cover-art"
|
||||||
@mouseenter=${this.handleCoverMouseEnter}
|
@mouseenter=${this.handleCoverMouseEnter}
|
||||||
@@ -500,6 +542,15 @@ export class NowPlaying extends LitElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Open the full-screen view. Phone only; see `.expand`. */
|
||||||
|
private openNowPlaying = () => {
|
||||||
|
this.dispatchEvent(new CustomEvent('navigate', {
|
||||||
|
detail: { view: 'now-playing' },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
// SCROLL LOGIC
|
// SCROLL LOGIC
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|||||||
@@ -67,6 +67,21 @@ export class SearchBar extends LitElement {
|
|||||||
transition: border-color 0.15s ease;
|
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 {
|
.search-container:focus-within {
|
||||||
border-color: var(--yj-accent, #ffd43b);
|
border-color: var(--yj-accent, #ffd43b);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { LitElement, html, css } from 'lit';
|
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 '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
import { designTokens } from '../../styles/tokens.css';
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
|
|
||||||
@@ -166,6 +166,17 @@ export class AppSidebar extends LitElement {
|
|||||||
@state()
|
@state()
|
||||||
private collapsed = false;
|
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. */
|
/** The width the user chose, restored when the window grows back. */
|
||||||
private userWidth = DEFAULT_WIDTH;
|
private userWidth = DEFAULT_WIDTH;
|
||||||
|
|
||||||
@@ -344,7 +355,8 @@ export class AppSidebar extends LitElement {
|
|||||||
*/
|
*/
|
||||||
private applyViewportWidth() {
|
private applyViewportWidth() {
|
||||||
const narrow =
|
const narrow =
|
||||||
this.narrowViewport?.matches ?? false;
|
!this.expanded &&
|
||||||
|
(this.narrowViewport?.matches ?? false);
|
||||||
const width = narrow
|
const width = narrow
|
||||||
? MIN_WIDTH
|
? MIN_WIDTH
|
||||||
: this.userWidth;
|
: this.userWidth;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ solid/arrow-rotate-right
|
|||||||
solid/arrows-rotate
|
solid/arrows-rotate
|
||||||
solid/arrow-up-short-wide
|
solid/arrow-up-short-wide
|
||||||
solid/backward-step
|
solid/backward-step
|
||||||
|
solid/bars
|
||||||
regular/bookmark
|
regular/bookmark
|
||||||
solid/bookmark
|
solid/bookmark
|
||||||
solid/box-open
|
solid/box-open
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* Long-press as the touch equivalent of a right-click (plan 016 B2,
|
||||||
|
* phase 3).
|
||||||
|
*
|
||||||
|
* Every context menu in the app opens from a `contextmenu` event —
|
||||||
|
* `track-list` and `queue-panel` delegate one on their virtualizer,
|
||||||
|
* the card grids and both playlist detail views bind one per row, and
|
||||||
|
* `explore-artist-details` binds three. A phone has no right-click, so
|
||||||
|
* a phone reached none of them.
|
||||||
|
*
|
||||||
|
* **This is one document listener, not six components' worth of touch
|
||||||
|
* handling.** A press that stays still for `LONG_PRESS_MS` dispatches a
|
||||||
|
* synthetic `contextmenu` at the touch point on the element the touch
|
||||||
|
* actually landed on, and every existing handler — delegated or
|
||||||
|
* per-row, in any shadow root — runs unchanged. Six implementations of
|
||||||
|
* a gesture is exactly the fault `ContextMenuController` exists to
|
||||||
|
* prevent, and a seam that needs no component to opt in cannot be
|
||||||
|
* forgotten by the next component.
|
||||||
|
*
|
||||||
|
* Three things about it are load-bearing.
|
||||||
|
*
|
||||||
|
* **The target comes from `composedPath()[0]`, not from
|
||||||
|
* `elementFromPoint`**, which stops at the outermost shadow host: every
|
||||||
|
* menu in this app is bound inside one, so a synthetic event dispatched
|
||||||
|
* on the host reaches a delegated listener and no per-row one.
|
||||||
|
*
|
||||||
|
* **A browser that already does this must win.** Chromium fires a
|
||||||
|
* `contextmenu` on long-press itself; WebKitGTK and the Android WebView
|
||||||
|
* vary. So one arriving during the press cancels ours, and one arriving
|
||||||
|
* just after ours is swallowed at document capture — where nothing else
|
||||||
|
* has seen it yet. The two are told apart by **identity** (a `WeakSet`
|
||||||
|
* of the events this module made) rather than by `isTrusted`, so the
|
||||||
|
* suppressor cannot eat the event it exists to deliver, the rule holds
|
||||||
|
* for anything else in the app that synthesises one, and a test can
|
||||||
|
* stand in for a browser that fires its own.
|
||||||
|
*
|
||||||
|
* **The click that ends the gesture is swallowed.** A row's click
|
||||||
|
* selects, and a card's plays; without this, opening a menu also
|
||||||
|
* activates the thing under it. It is keyed on the gesture (cleared by
|
||||||
|
* the next `pointerdown`) rather than on a time window, so a quick tap
|
||||||
|
* on the menu that just opened is not eaten too.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** How long a press must hold still to mean "menu". */
|
||||||
|
export const LONG_PRESS_MS = 500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far a press may drift and still count. Below a finger's own
|
||||||
|
* jitter is a gesture nobody can perform; above ~12px it starts
|
||||||
|
* stealing the first frames of a scroll.
|
||||||
|
*/
|
||||||
|
export const MOVE_TOLERANCE_PX = 10;
|
||||||
|
|
||||||
|
/** The active installation, so a second call is a no-op rather than a
|
||||||
|
* second listener set. */
|
||||||
|
let uninstall: (() => void) | null = null;
|
||||||
|
|
||||||
|
/** The events this module dispatched. Identity, not `isTrusted`: see
|
||||||
|
* the note above. */
|
||||||
|
const ours = new WeakSet<Event>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install the gesture. Idempotent; returns the uninstaller (which the
|
||||||
|
* tests use — the app installs once and never removes it).
|
||||||
|
*/
|
||||||
|
export function installLongPressContextMenu(): () => void {
|
||||||
|
if (uninstall) return uninstall;
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let originX = 0;
|
||||||
|
let originY = 0;
|
||||||
|
let target: EventTarget | null = null;
|
||||||
|
|
||||||
|
/** A trusted `contextmenu` arrived for this press: the browser has
|
||||||
|
* it covered. */
|
||||||
|
let nativeSeen = false;
|
||||||
|
|
||||||
|
/** We opened a menu, and the click ending that gesture is not a
|
||||||
|
* click on anything. */
|
||||||
|
let swallowClick = false;
|
||||||
|
|
||||||
|
/** We dispatched one, so a trusted one arriving now is a duplicate. */
|
||||||
|
let justFired = false;
|
||||||
|
|
||||||
|
const cancel = (): void => {
|
||||||
|
if (timer !== null) clearTimeout(timer);
|
||||||
|
|
||||||
|
timer = null;
|
||||||
|
target = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fire = (): void => {
|
||||||
|
timer = null;
|
||||||
|
|
||||||
|
const el = target;
|
||||||
|
|
||||||
|
target = null;
|
||||||
|
|
||||||
|
if (nativeSeen || !el) return;
|
||||||
|
|
||||||
|
justFired = true;
|
||||||
|
swallowClick = true;
|
||||||
|
|
||||||
|
const menu = new MouseEvent('contextmenu', {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
// Or it stops at the shadow root the row lives in, and the
|
||||||
|
// delegated listeners never see it.
|
||||||
|
composed: true,
|
||||||
|
clientX: originX,
|
||||||
|
clientY: originY,
|
||||||
|
button: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
ours.add(menu);
|
||||||
|
el.dispatchEvent(menu);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDown = (e: PointerEvent): void => {
|
||||||
|
// A new gesture: whatever the last one left behind is stale.
|
||||||
|
swallowClick = false;
|
||||||
|
justFired = false;
|
||||||
|
nativeSeen = false;
|
||||||
|
cancel();
|
||||||
|
|
||||||
|
if (e.pointerType !== 'touch' || !e.isPrimary) return;
|
||||||
|
|
||||||
|
originX = e.clientX;
|
||||||
|
originY = e.clientY;
|
||||||
|
target = e.composedPath()[0] ?? e.target;
|
||||||
|
timer = setTimeout(fire, LONG_PRESS_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (e: PointerEvent): void => {
|
||||||
|
if (timer === null) return;
|
||||||
|
|
||||||
|
const drifted =
|
||||||
|
Math.abs(e.clientX - originX) > MOVE_TOLERANCE_PX ||
|
||||||
|
Math.abs(e.clientY - originY) > MOVE_TOLERANCE_PX;
|
||||||
|
|
||||||
|
if (drifted) cancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onContextMenu = (e: Event): void => {
|
||||||
|
// Ours. Everything below is about somebody else's.
|
||||||
|
if (ours.has(e)) return;
|
||||||
|
|
||||||
|
if (timer !== null) {
|
||||||
|
// The browser got there first, so stand down rather than
|
||||||
|
// opening the same menu twice.
|
||||||
|
nativeSeen = true;
|
||||||
|
cancel();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (justFired) {
|
||||||
|
justFired = false;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClick = (e: Event): void => {
|
||||||
|
if (!swallowClick) return;
|
||||||
|
|
||||||
|
swallowClick = false;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Capture throughout: a component handler that stops propagation
|
||||||
|
// (every context-menu handler in the app does) must not be able to
|
||||||
|
// hide the gesture from this, and the suppressors have to run
|
||||||
|
// before anything that would act on the event.
|
||||||
|
const opts = { capture: true } as const;
|
||||||
|
|
||||||
|
document.addEventListener('pointerdown', onPointerDown, opts);
|
||||||
|
document.addEventListener('pointermove', onPointerMove, opts);
|
||||||
|
document.addEventListener('pointerup', cancel, opts);
|
||||||
|
document.addEventListener('pointercancel', cancel, opts);
|
||||||
|
document.addEventListener('contextmenu', onContextMenu, opts);
|
||||||
|
document.addEventListener('click', onClick, opts);
|
||||||
|
// A scroll started by something other than the finger (momentum, a
|
||||||
|
// programmatic reveal) still means the press was not a press.
|
||||||
|
document.addEventListener('scroll', cancel, { capture: true, passive: true });
|
||||||
|
|
||||||
|
uninstall = () => {
|
||||||
|
cancel();
|
||||||
|
document.removeEventListener('pointerdown', onPointerDown, opts);
|
||||||
|
document.removeEventListener('pointermove', onPointerMove, opts);
|
||||||
|
document.removeEventListener('pointerup', cancel, opts);
|
||||||
|
document.removeEventListener('pointercancel', cancel, opts);
|
||||||
|
document.removeEventListener('contextmenu', onContextMenu, opts);
|
||||||
|
document.removeEventListener('click', onClick, opts);
|
||||||
|
document.removeEventListener('scroll', cancel, opts);
|
||||||
|
uninstall = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return uninstall;
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* "Ask the user for a folder", once.
|
||||||
|
*
|
||||||
|
* Three call sites want a directory — the first-run wizard, the library
|
||||||
|
* settings and the download clients' save path — and on desktop all
|
||||||
|
* three can use the platform's own dialog. On Android that dialog
|
||||||
|
* *returns an error*: the Storage Access Framework yields tree URIs
|
||||||
|
* rather than filesystem paths, and a path is what this app's library
|
||||||
|
* model is keyed on.
|
||||||
|
*
|
||||||
|
* So the platform test lives here rather than at each call site, which
|
||||||
|
* is the same rule `utils/binding.ts` and `utils/library-status.ts`
|
||||||
|
* follow: a fact about the platform is stated once, at the boundary.
|
||||||
|
*
|
||||||
|
* *Which* platform is asked of the backend, not of the Wails runtime's
|
||||||
|
* `System.IsAndroid()`. The dialog is backend code, so the backend is
|
||||||
|
* what knows whether it can open one; it answers for iOS at the same
|
||||||
|
* time; and it keeps this testable through the ordinary transport fake
|
||||||
|
* rather than a module mock.
|
||||||
|
*
|
||||||
|
* Returns the chosen path, or `null` if the user cancelled. Callers
|
||||||
|
* treat those the same way they always did — a falsy result means "no
|
||||||
|
* change" — so adopting this is a one-line edit at each site.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
DirectoryPicker,
|
||||||
|
HasNativeDirectoryPicker,
|
||||||
|
} from "@go/frontendutil/frontendutil.js";
|
||||||
|
|
||||||
|
import type { FolderPicker } from "../components/folder-picker/folder-picker";
|
||||||
|
|
||||||
|
let picker: FolderPicker | null = null;
|
||||||
|
let native: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asked once per session and remembered: it cannot change while the app
|
||||||
|
* is running, and a folder picker should not pay a round trip to find
|
||||||
|
* out which kind it is.
|
||||||
|
*/
|
||||||
|
function hasNative(): Promise<boolean> {
|
||||||
|
native ??= HasNativeDirectoryPicker().catch(() => true);
|
||||||
|
|
||||||
|
return native;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Testing seam: forget the cached platform answer. */
|
||||||
|
export function resetDirectoryPickerCache(): void {
|
||||||
|
native = null;
|
||||||
|
picker = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The in-app browser is mounted on first use and then kept.
|
||||||
|
*
|
||||||
|
* Mounting it on demand and awaiting its module in the same update as
|
||||||
|
* `showModal()` is the trap `index.ts` documents for views — so the
|
||||||
|
* element is created, appended and *then* asked to open, on separate
|
||||||
|
* turns.
|
||||||
|
*/
|
||||||
|
async function inAppPicker(): Promise<FolderPicker> {
|
||||||
|
if (picker) return picker;
|
||||||
|
|
||||||
|
await import("../components/folder-picker/folder-picker");
|
||||||
|
|
||||||
|
const el = document.createElement("folder-picker");
|
||||||
|
|
||||||
|
document.body.appendChild(el);
|
||||||
|
picker = el;
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ask for a directory. Resolves to an absolute path, or null. */
|
||||||
|
export async function pickDirectory(startAt?: string): Promise<string | null> {
|
||||||
|
if (!(await hasNative())) {
|
||||||
|
const el = await inAppPicker();
|
||||||
|
|
||||||
|
return el.choose(startAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The desktop dialog returns '' when dismissed; normalise that to
|
||||||
|
// null so every caller has one falsy case to handle rather than
|
||||||
|
// two.
|
||||||
|
const chosen = await DirectoryPicker();
|
||||||
|
|
||||||
|
return chosen === "" ? null : chosen;
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* The phone's primary navigation (plan 016 B2).
|
||||||
|
*
|
||||||
|
* Three of these are about the thing that makes a second nav dangerous:
|
||||||
|
* it has to agree with the first one. `bottom-nav` emits the same
|
||||||
|
* bubbling, composed `navigate` event `app-sidebar` does and listens
|
||||||
|
* for that event globally, so a navigation from anywhere — a card, a
|
||||||
|
* detail view, the drawer's own sidebar — moves its highlight too. A
|
||||||
|
* tab bar that only tracks its own clicks looks right until the moment
|
||||||
|
* the user arrives somewhere by another route.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
import '@components/bottom-nav/bottom-nav';
|
||||||
|
import type { BottomNav } from '@components/bottom-nav/bottom-nav';
|
||||||
|
import { fixture, shadow, shadowAll, update } from '@test/support/render';
|
||||||
|
import { resetHarness } from '@test/support/harness';
|
||||||
|
|
||||||
|
type Nav = BottomNav;
|
||||||
|
|
||||||
|
const tabs = (el: HTMLElement) =>
|
||||||
|
shadowAll<HTMLButtonElement>(el, 'nav button');
|
||||||
|
|
||||||
|
/** Resolve on one occurrence of an event, or reject loudly on time. */
|
||||||
|
const once = (el: Element, name: string, timeoutMs = 2000) =>
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(
|
||||||
|
() => reject(new Error(`${name} never fired`)),
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
el.addEventListener(name, () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bottom-nav', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetHarness();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers the four phone destinations and a way to the rest', async () => {
|
||||||
|
const el = await fixture<Nav>('bottom-nav');
|
||||||
|
|
||||||
|
expect(tabs(el).map((b) => b.dataset.testid)).toEqual([
|
||||||
|
'tab-home',
|
||||||
|
'tab-albums',
|
||||||
|
'tab-tracks',
|
||||||
|
'tab-playlists',
|
||||||
|
'tab-more',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits a navigate event that escapes its shadow root', async () => {
|
||||||
|
const el = await fixture<Nav>('bottom-nav');
|
||||||
|
const seen: string[] = [];
|
||||||
|
|
||||||
|
document.addEventListener('navigate', (e) => {
|
||||||
|
seen.push((e as CustomEvent<{ view: string }>).detail.view);
|
||||||
|
});
|
||||||
|
|
||||||
|
shadow<HTMLButtonElement>(el, '[data-testid="tab-albums"]')?.click();
|
||||||
|
|
||||||
|
// Composed and bubbling, or index.ts's document-level listener --
|
||||||
|
// the only thing that actually changes the view -- never hears it.
|
||||||
|
expect(seen).toEqual(['albums']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows a navigation it did not send', async () => {
|
||||||
|
const el = await fixture<Nav>('bottom-nav');
|
||||||
|
|
||||||
|
document.dispatchEvent(new CustomEvent('navigate', {
|
||||||
|
detail: { view: 'tracks' },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
await update(el, {});
|
||||||
|
|
||||||
|
const current = tabs(el)
|
||||||
|
.filter((b) => b.getAttribute('aria-current') === 'page')
|
||||||
|
.map((b) => b.dataset.testid);
|
||||||
|
|
||||||
|
expect(current).toEqual(['tab-tracks']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks exactly one tab current, and none for a view it has no tab for', async () => {
|
||||||
|
const el = await fixture<Nav>('bottom-nav');
|
||||||
|
|
||||||
|
document.dispatchEvent(new CustomEvent('navigate', {
|
||||||
|
detail: { view: 'settings' },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
await update(el, {});
|
||||||
|
|
||||||
|
// Settings lives in the drawer, so nothing in the bar is current.
|
||||||
|
// Leaving Home highlighted would be a tab bar lying about where
|
||||||
|
// the user is.
|
||||||
|
expect(
|
||||||
|
tabs(el).filter((b) => b.getAttribute('aria-current') === 'page'),
|
||||||
|
).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes the drawer when a navigation happens', async () => {
|
||||||
|
const el = await fixture<Nav>('bottom-nav');
|
||||||
|
const drawer = shadow<HTMLElement & { open: boolean }>(el, 'wa-drawer');
|
||||||
|
|
||||||
|
if (!drawer) throw new Error('no drawer');
|
||||||
|
|
||||||
|
// The drawer animates, so the assertion is its own event rather
|
||||||
|
// than the `open` property: setting `open = false` starts a hide
|
||||||
|
// that has not finished on the next microtask, and a test that
|
||||||
|
// reads the property in between sees the state it is leaving.
|
||||||
|
const shown = once(drawer, 'wa-after-show');
|
||||||
|
|
||||||
|
shadow<HTMLButtonElement>(el, '[data-testid="tab-more"]')?.click();
|
||||||
|
await shown;
|
||||||
|
|
||||||
|
const hidden = once(drawer, 'wa-after-hide');
|
||||||
|
|
||||||
|
document.dispatchEvent(new CustomEvent('navigate', {
|
||||||
|
detail: { view: 'settings' },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await hidden;
|
||||||
|
expect(drawer.open).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives every tab a name and a target big enough to hit', async () => {
|
||||||
|
const el = await fixture<Nav>('bottom-nav');
|
||||||
|
|
||||||
|
for (const button of tabs(el)) {
|
||||||
|
expect(button.textContent?.trim()).not.toBe('');
|
||||||
|
// 48px is the floor for a touch target; the bar is the one
|
||||||
|
// surface in this app that has no pointer to fall back on.
|
||||||
|
expect(button.getBoundingClientRect().height).toBeGreaterThanOrEqual(48);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds no second sidebar until the drawer is asked for', async () => {
|
||||||
|
const el = await fixture<Nav>('bottom-nav');
|
||||||
|
|
||||||
|
// `app-sidebar` carries a data-testid per destination, so a spare
|
||||||
|
// copy standing by makes every `nav-*` testid ambiguous for the
|
||||||
|
// *whole app*: rendering it unconditionally failed 30 existing
|
||||||
|
// specs with "strict mode violation: resolved to 2 elements", on a
|
||||||
|
// desktop viewport where this element is not even visible.
|
||||||
|
expect(shadow(el, 'app-sidebar')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the drawer sidebar expanded, where there is room for labels', async () => {
|
||||||
|
const el = await fixture<Nav>('bottom-nav');
|
||||||
|
|
||||||
|
shadow<HTMLButtonElement>(el, '[data-testid="tab-more"]')?.click();
|
||||||
|
await update(el, {});
|
||||||
|
|
||||||
|
// Without this the sidebar's own auto-collapse (a response to a
|
||||||
|
// narrow *shell*) would render icons in a full-width drawer.
|
||||||
|
expect(shadow<HTMLElement>(el, 'app-sidebar')?.hasAttribute('expanded'))
|
||||||
|
.toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { wails } from '../support/wails-fake';
|
||||||
|
|
||||||
|
import type { FolderPicker } from '@components/folder-picker/folder-picker';
|
||||||
|
|
||||||
|
const listings: Record<string, unknown> = {
|
||||||
|
'/storage/emulated/0': {
|
||||||
|
path: '/storage/emulated/0',
|
||||||
|
parent: '/storage/emulated',
|
||||||
|
entries: [
|
||||||
|
{ name: 'Music', path: '/storage/emulated/0/Music' },
|
||||||
|
{ name: 'Podcasts', path: '/storage/emulated/0/Podcasts' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'/storage/emulated/0/Music': {
|
||||||
|
path: '/storage/emulated/0/Music',
|
||||||
|
parent: '/storage/emulated/0',
|
||||||
|
entries: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `pickDirectory` imports the picker's chunk before it can create the
|
||||||
|
* element, so the element does not exist on the turn the call is made.
|
||||||
|
* That is deliberate -- mounting a dialog and calling showModal() in
|
||||||
|
* one update is the trap `index.ts` documents -- so the test waits for
|
||||||
|
* it rather than assuming it is synchronous.
|
||||||
|
*/
|
||||||
|
async function host(): Promise<FolderPicker> {
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const el = document.querySelector<FolderPicker>('folder-picker');
|
||||||
|
|
||||||
|
if (el) {
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('folder-picker did not mount itself');
|
||||||
|
}
|
||||||
|
|
||||||
|
function click(el: FolderPicker, testid: string): void {
|
||||||
|
el.shadowRoot
|
||||||
|
?.querySelector<HTMLButtonElement>(`[data-testid="${testid}"]`)
|
||||||
|
?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
wails.stub('frontendutil.FrontendUtil.HasNativeDirectoryPicker', true);
|
||||||
|
wails.stub('frontendutil.FrontendUtil.DirectoryPicker', '/home/logan/Music');
|
||||||
|
wails.stub('frontendutil.FrontendUtil.DefaultBrowseRoot', '/storage/emulated/0');
|
||||||
|
wails.stub('frontendutil.FrontendUtil.ListDirectories', (path: string) => {
|
||||||
|
const listing = listings[path || '/storage/emulated/0'];
|
||||||
|
|
||||||
|
if (!listing) throw new Error('permission denied');
|
||||||
|
|
||||||
|
return listing;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.querySelector('folder-picker')?.remove();
|
||||||
|
wails.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pickDirectory', () => {
|
||||||
|
it('uses the platform dialog off Android', async () => {
|
||||||
|
const { pickDirectory, resetDirectoryPickerCache } = await import(
|
||||||
|
'@utils/pick-directory'
|
||||||
|
);
|
||||||
|
|
||||||
|
resetDirectoryPickerCache();
|
||||||
|
|
||||||
|
await expect(pickDirectory()).resolves.toBe('/home/logan/Music');
|
||||||
|
expect(document.querySelector('folder-picker')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalises the desktop dialog\u2019s empty string to null', async () => {
|
||||||
|
wails.stub('frontendutil.FrontendUtil.DirectoryPicker', '');
|
||||||
|
|
||||||
|
const { pickDirectory, resetDirectoryPickerCache } = await import(
|
||||||
|
'@utils/pick-directory'
|
||||||
|
);
|
||||||
|
|
||||||
|
resetDirectoryPickerCache();
|
||||||
|
|
||||||
|
await expect(pickDirectory()).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('browses in-app on Android, and never opens the platform dialog', async () => {
|
||||||
|
wails.stub('frontendutil.FrontendUtil.HasNativeDirectoryPicker', false);
|
||||||
|
|
||||||
|
const { pickDirectory, resetDirectoryPickerCache } = await import(
|
||||||
|
'@utils/pick-directory'
|
||||||
|
);
|
||||||
|
|
||||||
|
resetDirectoryPickerCache();
|
||||||
|
const answer = pickDirectory();
|
||||||
|
|
||||||
|
const el = await host();
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
click(el, 'folder-picker-select');
|
||||||
|
|
||||||
|
await expect(answer).resolves.toBe('/storage/emulated/0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves null when the browser is cancelled', async () => {
|
||||||
|
wails.stub('frontendutil.FrontendUtil.HasNativeDirectoryPicker', false);
|
||||||
|
|
||||||
|
const { pickDirectory, resetDirectoryPickerCache } = await import(
|
||||||
|
'@utils/pick-directory'
|
||||||
|
);
|
||||||
|
|
||||||
|
resetDirectoryPickerCache();
|
||||||
|
const answer = pickDirectory();
|
||||||
|
|
||||||
|
const el = await host();
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
el.shadowRoot
|
||||||
|
?.querySelectorAll<HTMLButtonElement>('.actions button')[0]
|
||||||
|
?.click();
|
||||||
|
|
||||||
|
await expect(answer).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('descends into a folder and returns the one it is showing', async () => {
|
||||||
|
wails.stub('frontendutil.FrontendUtil.HasNativeDirectoryPicker', false);
|
||||||
|
|
||||||
|
const { pickDirectory, resetDirectoryPickerCache } = await import(
|
||||||
|
'@utils/pick-directory'
|
||||||
|
);
|
||||||
|
|
||||||
|
resetDirectoryPickerCache();
|
||||||
|
const answer = pickDirectory();
|
||||||
|
|
||||||
|
const el = await host();
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
const music = [
|
||||||
|
...(el.shadowRoot?.querySelectorAll<HTMLButtonElement>(
|
||||||
|
'[data-testid="folder-picker-list"] button',
|
||||||
|
) ?? []),
|
||||||
|
].find((b) => b.textContent?.includes('Music'));
|
||||||
|
|
||||||
|
music?.click();
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
click(el, 'folder-picker-select');
|
||||||
|
|
||||||
|
await expect(answer).resolves.toBe('/storage/emulated/0/Music');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A directory that cannot be read is not a failed picker. Android's
|
||||||
|
* storage root holds directories no app may enter, and stranding the
|
||||||
|
* user in an empty dialog with no way back is worse than saying so.
|
||||||
|
*/
|
||||||
|
it('stays put and explains when a folder cannot be opened', async () => {
|
||||||
|
wails.stub('frontendutil.FrontendUtil.HasNativeDirectoryPicker', false);
|
||||||
|
|
||||||
|
const { pickDirectory, resetDirectoryPickerCache } = await import(
|
||||||
|
'@utils/pick-directory'
|
||||||
|
);
|
||||||
|
|
||||||
|
resetDirectoryPickerCache();
|
||||||
|
const answer = pickDirectory();
|
||||||
|
|
||||||
|
const el = await host();
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// 'Podcasts' has no listing, so ListDirectories rejects.
|
||||||
|
const bad = [
|
||||||
|
...(el.shadowRoot?.querySelectorAll<HTMLButtonElement>(
|
||||||
|
'[data-testid="folder-picker-list"] button',
|
||||||
|
) ?? []),
|
||||||
|
].find((b) => b.textContent?.includes('Podcasts'));
|
||||||
|
|
||||||
|
bad?.click();
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(el.shadowRoot?.querySelector('[role="alert"]')).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
el.shadowRoot?.querySelector('[data-testid="folder-picker-path"]')
|
||||||
|
?.textContent,
|
||||||
|
).toContain('/storage/emulated/0');
|
||||||
|
|
||||||
|
click(el, 'folder-picker-select');
|
||||||
|
await expect(answer).resolves.toBe('/storage/emulated/0');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* Long-press as the touch route to a context menu (plan 016 B2 phase 3).
|
||||||
|
*
|
||||||
|
* These run in a real browser with real event dispatch, which is the
|
||||||
|
* only place the two things that make this hard are true: the synthetic
|
||||||
|
* event has to cross a shadow boundary to reach the listener a
|
||||||
|
* component actually bound, and the suppressors have to tell a trusted
|
||||||
|
* event from ours at document capture without eating the one they exist
|
||||||
|
* to deliver.
|
||||||
|
*
|
||||||
|
* The timings are real rather than faked, because the thing under test
|
||||||
|
* *is* a timing, and 600 ms twice is cheaper than a fake-timer harness
|
||||||
|
* that would also have to fake the pointer events.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, afterEach, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
installLongPressContextMenu,
|
||||||
|
LONG_PRESS_MS,
|
||||||
|
MOVE_TOLERANCE_PX,
|
||||||
|
} from '@utils/long-press';
|
||||||
|
|
||||||
|
/** A press that has certainly resolved, either way. */
|
||||||
|
const HELD = LONG_PRESS_MS + 120;
|
||||||
|
|
||||||
|
/** A press that has certainly not. */
|
||||||
|
const BRIEF = Math.round(LONG_PRESS_MS / 4);
|
||||||
|
|
||||||
|
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
let uninstall: (() => void) | null = null;
|
||||||
|
let host: HTMLElement;
|
||||||
|
let inner: HTMLElement;
|
||||||
|
|
||||||
|
/** A row inside a shadow root, which is where every menu in this app
|
||||||
|
* is bound — an element in the light DOM would pass a weaker test. */
|
||||||
|
function mountRow(): { host: HTMLElement; inner: HTMLElement } {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const root = el.attachShadow({ mode: 'open' });
|
||||||
|
const row = document.createElement('div');
|
||||||
|
|
||||||
|
row.textContent = 'a track';
|
||||||
|
root.append(row);
|
||||||
|
document.body.append(el);
|
||||||
|
|
||||||
|
return { host: el, inner: row };
|
||||||
|
}
|
||||||
|
|
||||||
|
function press(
|
||||||
|
el: EventTarget,
|
||||||
|
type: string,
|
||||||
|
init: PointerEventInit = {},
|
||||||
|
): void {
|
||||||
|
el.dispatchEvent(
|
||||||
|
new PointerEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
cancelable: true,
|
||||||
|
pointerType: 'touch',
|
||||||
|
isPrimary: true,
|
||||||
|
clientX: 40,
|
||||||
|
clientY: 60,
|
||||||
|
...init,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record every `contextmenu` that reaches the listener, *as the
|
||||||
|
* listener sees it*.
|
||||||
|
*
|
||||||
|
* `target` is retargeted for the scope reading it, so an assertion made
|
||||||
|
* after dispatch has finished reports the shadow host however the event
|
||||||
|
* was dispatched - which is the same answer a broken implementation
|
||||||
|
* gives. It has to be read from inside the handler, where the component
|
||||||
|
* reads it.
|
||||||
|
*/
|
||||||
|
function recordMenus(el: EventTarget): { event: MouseEvent; target: EventTarget | null }[] {
|
||||||
|
const seen: { event: MouseEvent; target: EventTarget | null }[] = [];
|
||||||
|
|
||||||
|
el.addEventListener('contextmenu', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
// Every real handler does this; the gesture must work anyway.
|
||||||
|
e.stopPropagation();
|
||||||
|
seen.push({ event: e as MouseEvent, target: e.target });
|
||||||
|
});
|
||||||
|
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('long-press opens a context menu', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
uninstall = installLongPressContextMenu();
|
||||||
|
({ host, inner } = mountRow());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
uninstall?.();
|
||||||
|
uninstall = null;
|
||||||
|
host.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches one at the touch point, on the element touched', async () => {
|
||||||
|
const seen = recordMenus(inner);
|
||||||
|
|
||||||
|
press(inner, 'pointerdown');
|
||||||
|
await wait(HELD);
|
||||||
|
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
expect(seen[0]?.event.clientX).toBe(40);
|
||||||
|
expect(seen[0]?.event.clientY).toBe(60);
|
||||||
|
// Dispatched on the row itself, not on its shadow host - which is
|
||||||
|
// the difference between a per-row handler firing and only a
|
||||||
|
// delegated one firing.
|
||||||
|
expect(seen[0]?.target).toBe(inner);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is cancelled by a press that moves', async () => {
|
||||||
|
const seen = recordMenus(inner);
|
||||||
|
|
||||||
|
press(inner, 'pointerdown');
|
||||||
|
press(inner, 'pointermove', {
|
||||||
|
clientX: 40 + MOVE_TOLERANCE_PX + 5,
|
||||||
|
clientY: 60,
|
||||||
|
});
|
||||||
|
await wait(HELD);
|
||||||
|
|
||||||
|
expect(seen).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates the jitter a finger cannot help', async () => {
|
||||||
|
const seen = recordMenus(inner);
|
||||||
|
|
||||||
|
press(inner, 'pointerdown');
|
||||||
|
press(inner, 'pointermove', { clientX: 43, clientY: 62 });
|
||||||
|
await wait(HELD);
|
||||||
|
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is cancelled by lifting early, and by a scroll', async () => {
|
||||||
|
const seen = recordMenus(inner);
|
||||||
|
|
||||||
|
press(inner, 'pointerdown');
|
||||||
|
await wait(BRIEF);
|
||||||
|
press(inner, 'pointerup');
|
||||||
|
await wait(HELD);
|
||||||
|
|
||||||
|
expect(seen).toHaveLength(0);
|
||||||
|
|
||||||
|
press(inner, 'pointerdown');
|
||||||
|
press(inner, 'pointercancel');
|
||||||
|
await wait(HELD);
|
||||||
|
|
||||||
|
expect(seen).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a mouse, which has a right button of its own', async () => {
|
||||||
|
const seen = recordMenus(inner);
|
||||||
|
|
||||||
|
press(inner, 'pointerdown', { pointerType: 'mouse' });
|
||||||
|
await wait(HELD);
|
||||||
|
|
||||||
|
expect(seen).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swallows the click that ends the gesture, and only that one', async () => {
|
||||||
|
let clicks = 0;
|
||||||
|
|
||||||
|
inner.addEventListener('click', () => {
|
||||||
|
clicks += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
press(inner, 'pointerdown');
|
||||||
|
await wait(HELD);
|
||||||
|
press(inner, 'pointerup');
|
||||||
|
inner.click();
|
||||||
|
|
||||||
|
expect(clicks).toBe(0);
|
||||||
|
|
||||||
|
// The next tap is a tap: on a phone that is the user choosing an
|
||||||
|
// item in the menu that just opened, so eating it would make the
|
||||||
|
// gesture useless.
|
||||||
|
press(inner, 'pointerdown');
|
||||||
|
press(inner, 'pointerup');
|
||||||
|
inner.click();
|
||||||
|
|
||||||
|
expect(clicks).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stands down where the browser fires its own', async () => {
|
||||||
|
const seen = recordMenus(inner);
|
||||||
|
|
||||||
|
press(inner, 'pointerdown');
|
||||||
|
await wait(BRIEF);
|
||||||
|
// Chromium does this itself on touch; WebKit and the Android
|
||||||
|
// WebView vary, which is the whole reason both halves exist. A
|
||||||
|
// test cannot dispatch a *trusted* event, which is why the module
|
||||||
|
// tells its own apart by identity rather than by `isTrusted`.
|
||||||
|
inner.dispatchEvent(
|
||||||
|
new MouseEvent('contextmenu', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await wait(HELD);
|
||||||
|
|
||||||
|
// One menu: the browser's. Not two.
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* The full-screen now-playing view (plan 016 B2, phase 2).
|
||||||
|
*
|
||||||
|
* What is worth pinning here is not the layout but the *composition*:
|
||||||
|
* it renders the same `<seek-bar>`, `<player-controls>` and
|
||||||
|
* `<volume-control>` the desktop transport does, rather than its own.
|
||||||
|
* A phone layout that reimplements the transport is a second transport
|
||||||
|
* to fix every bug in — and the seek bar in particular carries
|
||||||
|
* interpolation rules that took a plan of their own to get right.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
import '@components/now-playing-view/now-playing-view';
|
||||||
|
import { Events } from '../../src/events';
|
||||||
|
import { emit, resetHarness, stub } from '@test/support/harness';
|
||||||
|
import { fixture, shadow, text } from '@test/support/render';
|
||||||
|
import type { TrackInfo } from '@store/player-store';
|
||||||
|
|
||||||
|
const TRACK: TrackInfo = {
|
||||||
|
fileName: 'tideline.mp3',
|
||||||
|
filePath: '/music/tideline.mp3',
|
||||||
|
trackLength: 245,
|
||||||
|
seekPosition: 0,
|
||||||
|
state: 'playing',
|
||||||
|
title: 'Tideline',
|
||||||
|
artist: 'Sea Change',
|
||||||
|
album: 'Ebb',
|
||||||
|
coverArt: '/covers/ebb.jpg',
|
||||||
|
coverArtSmall: '/covers/ebb_sm.jpg',
|
||||||
|
coverArtMedium: '/covers/ebb_md.jpg',
|
||||||
|
coverArtLarge: '/covers/ebb_lg.jpg',
|
||||||
|
trackChangeId: 1,
|
||||||
|
artistMbid: '',
|
||||||
|
releaseGroupMbid: '',
|
||||||
|
recordingMbid: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('now-playing-view', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetHarness();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses the real transport components', async () => {
|
||||||
|
emit(Events.TrackChanged, TRACK);
|
||||||
|
|
||||||
|
const el = await fixture('now-playing-view');
|
||||||
|
|
||||||
|
for (const tag of ['seek-bar', 'player-controls', 'volume-control']) {
|
||||||
|
expect(shadow(el, tag), `${tag} is not rendered`).not.toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the track, and the largest cover tier that is kept', async () => {
|
||||||
|
emit(Events.TrackChanged, TRACK);
|
||||||
|
|
||||||
|
const el = await fixture('now-playing-view');
|
||||||
|
|
||||||
|
expect(text(el, '[data-testid="npv-title"]')).toBe('Tideline');
|
||||||
|
|
||||||
|
// `saveCoverArt` records the largest *tier* as the path; there is
|
||||||
|
// no full-resolution original on disk to reach for.
|
||||||
|
expect(
|
||||||
|
shadow<HTMLImageElement>(el, '[data-testid="npv-art"]')?.getAttribute('src'),
|
||||||
|
).toBe('/covers/ebb_lg.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says so when nothing is playing, rather than rendering an empty frame', async () => {
|
||||||
|
// The player store is a singleton and outlives a test, so "no
|
||||||
|
// track" has to be stated rather than assumed from a fresh mount.
|
||||||
|
emit(Events.TrackChanged, null);
|
||||||
|
|
||||||
|
const el = await fixture('now-playing-view');
|
||||||
|
|
||||||
|
expect(shadow(el, '[data-testid="npv-empty"]')).not.toBeNull();
|
||||||
|
expect(shadow(el, '[data-testid="npv-art"]')).toBeNull();
|
||||||
|
|
||||||
|
// …and the way out is still there, which is the whole point of
|
||||||
|
// rendering the header in both branches.
|
||||||
|
expect(shadow(el, '[data-testid="npv-back"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves by the nav stack, not by guessing where it came from', async () => {
|
||||||
|
emit(Events.TrackChanged, TRACK);
|
||||||
|
|
||||||
|
const el = await fixture('now-playing-view');
|
||||||
|
let backs = 0;
|
||||||
|
|
||||||
|
document.addEventListener('navigate-back', () => {
|
||||||
|
backs += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
shadow<HTMLButtonElement>(el, '[data-testid="npv-back"]')?.click();
|
||||||
|
|
||||||
|
// `navigate-back` pops what index.ts pushed. Dispatching a
|
||||||
|
// `navigate` to a hardcoded view would strand anyone who arrived
|
||||||
|
// here from a detail page.
|
||||||
|
expect(backs).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the favourite button a target and a state', async () => {
|
||||||
|
stub('playlist.Service.ToggleFavorite', undefined);
|
||||||
|
emit(Events.TrackChanged, TRACK);
|
||||||
|
|
||||||
|
const el = await fixture('now-playing-view');
|
||||||
|
const fav = shadow<HTMLButtonElement>(el, '[data-testid="npv-favorite"]');
|
||||||
|
|
||||||
|
expect(fav).not.toBeNull();
|
||||||
|
expect(fav?.getAttribute('aria-pressed')).toBe('false');
|
||||||
|
|
||||||
|
// A button that says only "heart" says nothing; the name carries
|
||||||
|
// the track and the playlist it goes to.
|
||||||
|
expect(fav?.getAttribute('aria-label')).toContain('Tideline');
|
||||||
|
|
||||||
|
expect(fav!.getBoundingClientRect().height).toBeGreaterThanOrEqual(48);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the way out a thumb-sized target', async () => {
|
||||||
|
emit(Events.TrackChanged, TRACK);
|
||||||
|
|
||||||
|
const el = await fixture('now-playing-view');
|
||||||
|
const back = shadow<HTMLButtonElement>(el, '[data-testid="npv-back"]');
|
||||||
|
|
||||||
|
expect(back!.getBoundingClientRect().height).toBeGreaterThanOrEqual(48);
|
||||||
|
expect(back?.getAttribute('aria-label')).toBe('Back');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"yellowjacket/backend/assets"
|
"yellowjacket/backend/assets"
|
||||||
"yellowjacket/backend/config"
|
"yellowjacket/backend/config"
|
||||||
"yellowjacket/backend/profiling"
|
"yellowjacket/backend/profiling"
|
||||||
|
"yellowjacket/backend/system"
|
||||||
"yellowjacket/internal/dev"
|
"yellowjacket/internal/dev"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,6 +29,20 @@ var (
|
|||||||
var frontendDistAssets embed.FS
|
var frontendDistAssets embed.FS
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// **Mobile has no home directory, and this must run before anything
|
||||||
|
// asks for a path.** backend/system resolves config and data from
|
||||||
|
// $HOME or the OS equivalent, and on Android there is neither: its
|
||||||
|
// switch on runtime.GOOS took the default branch and returned
|
||||||
|
// errUnsupportedOS, so NewYellowJacketApp failed and main() exited
|
||||||
|
// six milliseconds after the JNI bridge came up -- with no panic and
|
||||||
|
// no tombstone, because os.Exit is not a crash.
|
||||||
|
//
|
||||||
|
// StoragePath() is the platform's own answer (getFilesDir() on
|
||||||
|
// Android, Application Support on iOS) and returns "" on desktop,
|
||||||
|
// where UseHomeOverride is then a no-op -- so this needs no build
|
||||||
|
// tag and changes nothing off mobile.
|
||||||
|
system.UseHomeOverride(application.Mobile.StoragePath())
|
||||||
|
|
||||||
// WebKitGTK's DMABuf renderer crashes on NVIDIA GPUs under Wayland.
|
// WebKitGTK's DMABuf renderer crashes on NVIDIA GPUs under Wayland.
|
||||||
// Only disable it for that specific combo so AMD/Intel and X11 users
|
// Only disable it for that specific combo so AMD/Intel and X11 users
|
||||||
// keep full hardware-accelerated buffer sharing. Users can also
|
// keep full hardware-accelerated buffer sharing. Users can also
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# The Android tier: an emulator, an APK, and a way to find out why the
|
||||||
|
# app died.
|
||||||
|
#
|
||||||
|
# This is the phone equivalent of `dev-headless.sh`, and it is
|
||||||
|
# deliberately shaped like it — start in the background and return,
|
||||||
|
# stop by saved state, tail a log — because the operating pattern is
|
||||||
|
# the one this repo already has. What is different is *what a failure
|
||||||
|
# looks like*, and that is the whole reason this script exists rather
|
||||||
|
# than a paragraph telling you to run adb.
|
||||||
|
#
|
||||||
|
# **Go's stdout does not reach logcat.** An Android app's fd 1 and 2 go
|
||||||
|
# to /dev/null, so every `slog` line the app writes — including the one
|
||||||
|
# naming the error it is about to exit on — is discarded. There is no
|
||||||
|
# flag for this: `setprop log.redirect-stdio true` redirects the *Java*
|
||||||
|
# runtime's System.out and does nothing for a c-shared Go library.
|
||||||
|
#
|
||||||
|
# **And `os.Exit` is a silent death.** `main()` ends several failure
|
||||||
|
# paths in `os.Exit(1)`; from Android's side that is a process that
|
||||||
|
# vanished, reported as "has died: fg TOP" and signal 9, with no panic,
|
||||||
|
# no `AndroidRuntime` stack and no tombstone — the three places anyone
|
||||||
|
# would look. ActivityManager then restarts it, so `pidof` answers with
|
||||||
|
# a pid and the app looks alive while crash-looping several times a
|
||||||
|
# second.
|
||||||
|
#
|
||||||
|
# `smoke` exists because of those two facts together: the honest test
|
||||||
|
# is not "did it start" but "is the same pid still there a few seconds
|
||||||
|
# later", and the useful output is the app's own logcat tags plus a
|
||||||
|
# named guess at which `os.Exit` it took.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
AVD="${YJ_AVD:-yj-test}"
|
||||||
|
SDK="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-$HOME/Android/Sdk}}"
|
||||||
|
PKG="${YJ_ANDROID_PKG:-app.yellowjacket}"
|
||||||
|
# Where `make android-inspect` forwards the WebView's devtools socket.
|
||||||
|
CDP_PORT="${YJ_ANDROID_CDP_PORT:-9222}"
|
||||||
|
# **Not "$PKG/.MainActivity".** A leading-dot activity is resolved
|
||||||
|
# relative to the *applicationId*, and the scaffold's activity lives in
|
||||||
|
# the Java package `com.wails.app`, which is deliberately not the
|
||||||
|
# applicationId (see app/build.gradle). The short form silently
|
||||||
|
# resolves to app.yellowjacket.MainActivity, which does not exist, and
|
||||||
|
# `am start` fails with a class-not-found that reads like a broken
|
||||||
|
# build rather than a wrong name.
|
||||||
|
ACTIVITY="${YJ_ANDROID_ACTIVITY:-com.wails.app.MainActivity}"
|
||||||
|
IMAGE="${YJ_ANDROID_IMAGE:-system-images;android-35;google_apis;x86_64}"
|
||||||
|
DEVDIR=".dev"
|
||||||
|
PIDFILE="$DEVDIR/emulator.pid"
|
||||||
|
LOGFILE="$DEVDIR/emulator.log"
|
||||||
|
|
||||||
|
ADB="$SDK/platform-tools/adb"
|
||||||
|
EMULATOR="$SDK/emulator/emulator"
|
||||||
|
SDKMANAGER="$SDK/cmdline-tools/latest/bin/sdkmanager"
|
||||||
|
AVDMANAGER="$SDK/cmdline-tools/latest/bin/avdmanager"
|
||||||
|
|
||||||
|
die() { echo "android: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
need_sdk() {
|
||||||
|
[ -x "$ADB" ] || die "no adb at $ADB — set ANDROID_SDK_ROOT, or run 'make android-setup'"
|
||||||
|
[ -x "$EMULATOR" ] || die "no emulator at $EMULATOR — run 'make android-setup'"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Address one device explicitly, because a bare `adb` addresses whatever
|
||||||
|
# is attached and there is very often something else attached: another
|
||||||
|
# project's emulator, or this one's own corpse left `offline` by a
|
||||||
|
# previous run. Both make every adb call here fail with "more than one
|
||||||
|
# device", which cmd_install then reports as "no device — run 'make
|
||||||
|
# android-emulator' first" *immediately after* that succeeded.
|
||||||
|
#
|
||||||
|
# The AVD name is the identity, not the serial: serials are assigned in
|
||||||
|
# boot order and change between runs. ANDROID_SERIAL is honoured if the
|
||||||
|
# caller set it, and is what every later `adb` in this script reads.
|
||||||
|
pick_device() {
|
||||||
|
[ -n "${ANDROID_SERIAL:-}" ] && return 0
|
||||||
|
|
||||||
|
online=$("$ADB" devices | awk '$2 == "device" { print $1 }')
|
||||||
|
[ -n "$online" ] || return 1
|
||||||
|
|
||||||
|
for serial in $online; do
|
||||||
|
name=$("$ADB" -s "$serial" shell getprop ro.boot.qemu.avd_name 2>/dev/null | tr -d '\r')
|
||||||
|
[ -n "$name" ] || name=$("$ADB" -s "$serial" shell getprop ro.kernel.qemu.avd_name 2>/dev/null | tr -d '\r')
|
||||||
|
if [ "$name" = "$AVD" ]; then
|
||||||
|
export ANDROID_SERIAL="$serial"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# No AVD of ours, but exactly one device: a physical phone, which is
|
||||||
|
# the one target this tier actually wants (see android-tier.md).
|
||||||
|
if [ "$(printf '%s\n' "$online" | wc -l)" -eq 1 ]; then
|
||||||
|
export ANDROID_SERIAL="$online"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "android: several devices and none is the '$AVD' AVD:" >&2
|
||||||
|
"$ADB" devices | sed '1d;/^$/d;s/^/ /' >&2
|
||||||
|
echo " set ANDROID_SERIAL to choose one" >&2
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# The emulator is the only long-lived process here, and it is addressed
|
||||||
|
# by its saved pid. Never by name: `pkill -f emulator` matches this
|
||||||
|
# script's own command line and kills the shell running it, which is
|
||||||
|
# the same trap dev-stop.sh documents.
|
||||||
|
running() {
|
||||||
|
[ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_setup() {
|
||||||
|
[ -x "$SDKMANAGER" ] || die "no sdkmanager at $SDKMANAGER; install the Android command line tools first"
|
||||||
|
|
||||||
|
# Each piece is installed only when missing. sdkmanager is itself
|
||||||
|
# idempotent but still spends minutes verifying, so the guards are
|
||||||
|
# what make this cheap to re-run.
|
||||||
|
for want in "platform-tools" "platforms;android-35" "build-tools;34.0.0" "$IMAGE"; do
|
||||||
|
dir="$SDK/$(printf '%s' "$want" | tr ';' '/')"
|
||||||
|
if [ -d "$dir" ]; then
|
||||||
|
echo " $want: present"
|
||||||
|
else
|
||||||
|
echo " $want: installing"
|
||||||
|
yes | "$SDKMANAGER" --install "$want" >/dev/null
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if "$EMULATOR" -list-avds 2>/dev/null | grep -qx "$AVD"; then
|
||||||
|
echo " avd $AVD: present"
|
||||||
|
else
|
||||||
|
echo " avd $AVD: creating"
|
||||||
|
echo no | "$AVDMANAGER" create avd -n "$AVD" -k "$IMAGE" -d pixel_6 --force >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A dependency with a requirement, checked like one. Without KVM the
|
||||||
|
# emulator falls back to full software emulation and a boot that
|
||||||
|
# takes 30 s takes 20 minutes — which reads as a hung target.
|
||||||
|
if ! "$EMULATOR" -accel-check 2>&1 | grep -q "is installed and usable"; then
|
||||||
|
echo
|
||||||
|
echo " WARNING: KVM is not usable. The emulator will run under software"
|
||||||
|
echo " emulation and boot times go from ~30s to tens of minutes."
|
||||||
|
echo " Check /dev/kvm exists and that you are in the kvm group."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_start() {
|
||||||
|
need_sdk
|
||||||
|
mkdir -p "$DEVDIR"
|
||||||
|
|
||||||
|
if running; then
|
||||||
|
echo "emulator already running (pid $(cat "$PIDFILE"))"
|
||||||
|
else
|
||||||
|
"$EMULATOR" -list-avds 2>/dev/null | grep -qx "$AVD" ||
|
||||||
|
die "no AVD named '$AVD' — run 'make android-setup'"
|
||||||
|
|
||||||
|
# -no-window because there is no display and does not need one;
|
||||||
|
# -no-snapshot so a run starts from the same state every time,
|
||||||
|
# which is what makes a smoke result mean something.
|
||||||
|
nohup "$EMULATOR" -avd "$AVD" \
|
||||||
|
-no-window -no-boot-anim -no-snapshot \
|
||||||
|
-gpu swiftshader_indirect \
|
||||||
|
-netdelay none -netspeed full \
|
||||||
|
>"$LOGFILE" 2>&1 &
|
||||||
|
echo $! >"$PIDFILE"
|
||||||
|
echo "emulator starting (pid $(cat "$PIDFILE")), log: $LOGFILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -n "waiting for boot"
|
||||||
|
"$ADB" wait-for-device >/dev/null 2>&1 || die "device never appeared; see $LOGFILE"
|
||||||
|
pick_device || die "the emulator booted but could not be addressed"
|
||||||
|
for _ in $(seq 1 150); do
|
||||||
|
if [ "$("$ADB" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = "1" ]; then
|
||||||
|
echo " ok"
|
||||||
|
"$ADB" shell getprop ro.build.version.release |
|
||||||
|
sed 's/^/ android /'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo -n .
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
die "boot did not complete in 300s; see $LOGFILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_stop() {
|
||||||
|
if running; then
|
||||||
|
pid=$(cat "$PIDFILE")
|
||||||
|
# The emulator's own console command shuts the guest down
|
||||||
|
# cleanly; the saved pid is the fallback and the guarantee.
|
||||||
|
"$ADB" emu kill >/dev/null 2>&1 || true
|
||||||
|
for _ in $(seq 1 15); do
|
||||||
|
kill -0 "$pid" 2>/dev/null || break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
kill -0 "$pid" 2>/dev/null && kill "$pid" 2>/dev/null || true
|
||||||
|
echo "emulator stopped"
|
||||||
|
else
|
||||||
|
echo "emulator not running"
|
||||||
|
fi
|
||||||
|
rm -f "$PIDFILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_install() {
|
||||||
|
need_sdk
|
||||||
|
[ -f bin/yellowjacket.apk ] || die "no bin/yellowjacket.apk — run 'make android' first"
|
||||||
|
pick_device || die "no device — run 'make android-emulator' first"
|
||||||
|
"$ADB" get-state >/dev/null 2>&1 || die "no device — run 'make android-emulator' first"
|
||||||
|
|
||||||
|
# The two ways this fails are both about identity rather than the
|
||||||
|
# build, and neither error says what to do about it.
|
||||||
|
#
|
||||||
|
# A *downgrade* is the versionCode rule working as designed: a bare
|
||||||
|
# `make android` produces versionCode 1, so it will not install over
|
||||||
|
# anything a versioned build left behind. A *signature* mismatch is
|
||||||
|
# the rule this whole pipeline exists for — a debug-signed local
|
||||||
|
# build cannot replace a release-signed one.
|
||||||
|
#
|
||||||
|
# Both are fixed by uninstalling, and on a throwaway emulator that
|
||||||
|
# costs nothing, so say so rather than making someone read the
|
||||||
|
# constant name.
|
||||||
|
out=$("$ADB" install -r bin/yellowjacket.apk 2>&1) || {
|
||||||
|
printf '%s\n' "$out"
|
||||||
|
case "$out" in
|
||||||
|
*INSTALL_FAILED_VERSION_DOWNGRADE*)
|
||||||
|
echo
|
||||||
|
echo "The installed copy has a higher versionCode than this build."
|
||||||
|
echo "A bare 'make android' builds versionCode 1; a versioned one"
|
||||||
|
echo "builds e.g. 10301. Either uninstall:"
|
||||||
|
echo " $ADB uninstall $PKG"
|
||||||
|
echo "or build with a version:"
|
||||||
|
echo " YJ_VERSION=1.3.1 YJ_VERSION_CODE=10301 make android"
|
||||||
|
;;
|
||||||
|
# The inner quotes are load-bearing: `do` is a reserved word, and
|
||||||
|
# an unquoted one in a case pattern is a syntax error that fails
|
||||||
|
# the parse of the *whole file* -- so every subcommand here died
|
||||||
|
# with "line 190: syntax error near unexpected token `do'", not
|
||||||
|
# just install.
|
||||||
|
*INSTALL_FAILED_UPDATE_INCOMPATIBLE* | *"signatures do not match"*)
|
||||||
|
echo
|
||||||
|
echo "The installed copy was signed with a different key. Android"
|
||||||
|
echo "never allows that as an update — which is exactly why CI"
|
||||||
|
echo "refuses to publish a debug-signed APK. Uninstall:"
|
||||||
|
echo " $ADB uninstall $PKG"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
printf '%s\n' "$out"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_launch() {
|
||||||
|
need_sdk
|
||||||
|
pick_device || die "no device — run 'make android-emulator' first"
|
||||||
|
"$ADB" shell am force-stop "$PKG"
|
||||||
|
"$ADB" logcat -c
|
||||||
|
"$ADB" shell am start -n "$PKG/$ACTIVITY" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_logs() {
|
||||||
|
need_sdk
|
||||||
|
pick_device || die "no device — run 'make android-emulator' first"
|
||||||
|
# The app's own tags plus the two that report its death. Chasing a
|
||||||
|
# raw logcat here is hopeless: the emulator emits thousands of lines
|
||||||
|
# a second, almost all of them WindowManager transitions.
|
||||||
|
"$ADB" logcat -v time \
|
||||||
|
WailsBridge:V "$PKG":V GoLog:V AndroidRuntime:E DEBUG:V libc:F ActivityManager:I '*:S'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Forward the WebView's devtools socket, so the page can be asked things.
|
||||||
|
#
|
||||||
|
# Only a `debuggable` build opens that socket, and a debug build carries
|
||||||
|
# `applicationIdSuffix ".dev"` precisely so it can be installed *beside*
|
||||||
|
# the release app: the two are signed by different certificates, and
|
||||||
|
# Android's remedy for a certificate change is an uninstall, which takes
|
||||||
|
# the user's library with it. So this looks for the sibling first and the
|
||||||
|
# release id second.
|
||||||
|
#
|
||||||
|
# The socket name carries the pid, which changes on every launch -- which
|
||||||
|
# is why this resolves it rather than documenting a number.
|
||||||
|
cmd_inspect() {
|
||||||
|
need_sdk
|
||||||
|
pick_device || die "no device -- plug a phone in (USB debugging on) or run 'make android-emulator'"
|
||||||
|
|
||||||
|
local pkg pid
|
||||||
|
pid=""
|
||||||
|
|
||||||
|
for pkg in "$PKG.dev" "$PKG"; do
|
||||||
|
pid=$("$ADB" shell pidof "$pkg" 2>/dev/null | tr -d '\r' | awk '{print $1}')
|
||||||
|
[ -n "$pid" ] && break
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -n "$pid" ] || die "neither $PKG.dev nor $PKG is running; launch it first"
|
||||||
|
|
||||||
|
"$ADB" forward --remove-all >/dev/null 2>&1 || true
|
||||||
|
"$ADB" forward "tcp:$CDP_PORT" "localabstract:webview_devtools_remote_$pid" >/dev/null \
|
||||||
|
|| die "adb forward failed"
|
||||||
|
|
||||||
|
echo "android: $pkg (pid $pid) devtools on http://localhost:$CDP_PORT"
|
||||||
|
echo " make android-eval EXPR='JSON.stringify({vp:[innerWidth,innerHeight]})'"
|
||||||
|
echo " (a release build has no devtools socket: build and install the debug one)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# What the phone is actually showing.
|
||||||
|
#
|
||||||
|
# This tier exists because no other one can see the platform: system
|
||||||
|
# bars, the safe area, the keyboard, an OEM's permission dialog. All of
|
||||||
|
# those are things you have to *look* at, and two of the three faults
|
||||||
|
# found so far were found by reading a picture rather than an assertion
|
||||||
|
# (`android-tier.md`).
|
||||||
|
#
|
||||||
|
# `exec-out` and not `shell`: `adb shell` runs the output through a pty
|
||||||
|
# on some platforms, which translates LF and corrupts the PNG -- for
|
||||||
|
# which the symptom is an image viewer refusing a file that downloaded
|
||||||
|
# perfectly.
|
||||||
|
cmd_screenshot() {
|
||||||
|
need_sdk
|
||||||
|
pick_device || die "no device \u2014 plug a phone in (USB debugging on) or run 'make android-emulator'"
|
||||||
|
|
||||||
|
local out="${1:-}"
|
||||||
|
|
||||||
|
[ -n "$out" ] || out=".dev/android-$(date +%Y%m%d-%H%M%S).png"
|
||||||
|
mkdir -p "$(dirname "$out")"
|
||||||
|
|
||||||
|
"$ADB" exec-out screencap -p > "$out" || die "screencap failed"
|
||||||
|
[ -s "$out" ] || die "screencap produced nothing (is the screen locked?)"
|
||||||
|
|
||||||
|
echo "android: screenshot -> $out"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Start the app and assert it is *still the same process* a few seconds
|
||||||
|
# later. "It started" is not the question — a crash-looping app starts
|
||||||
|
# continuously.
|
||||||
|
cmd_smoke() {
|
||||||
|
need_sdk
|
||||||
|
local wait_s="${1:-10}"
|
||||||
|
|
||||||
|
cmd_launch
|
||||||
|
sleep 3
|
||||||
|
# **`|| true` is load-bearing.** `pidof` exits 1 when it finds
|
||||||
|
# nothing, and under `set -e` a failing command substitution kills
|
||||||
|
# the script -- silently, before it can print why. That is invisible
|
||||||
|
# for as long as the app crash-*loops*, because there is always some
|
||||||
|
# pid; it appears the moment the app dies for good and ActivityManager
|
||||||
|
# stops respawning it, which is exactly the run you most want output
|
||||||
|
# from.
|
||||||
|
local first second
|
||||||
|
first=$("$ADB" shell pidof "$PKG" 2>/dev/null | tr -d '\r' | awk '{print $1}' || true)
|
||||||
|
sleep "$wait_s"
|
||||||
|
second=$("$ADB" shell pidof "$PKG" 2>/dev/null | tr -d '\r' | awk '{print $1}' || true)
|
||||||
|
|
||||||
|
if [ -n "$first" ] && [ "$first" = "$second" ]; then
|
||||||
|
echo "PASS: $PKG alive as pid $first after ${wait_s}s"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "FAIL: $PKG is not stable (pid was '${first:-none}', now '${second:-none}')"
|
||||||
|
if [ -n "$first" ] && [ -n "$second" ]; then
|
||||||
|
echo " The pid changed: it is crash-looping, not running."
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
echo "--- last 40 app-relevant logcat lines ---"
|
||||||
|
"$ADB" logcat -d -v time \
|
||||||
|
WailsBridge:V "$PKG":V GoLog:V AndroidRuntime:E DEBUG:V libc:F '*:S' 2>/dev/null |
|
||||||
|
tail -40
|
||||||
|
echo
|
||||||
|
echo "--- reading this ---"
|
||||||
|
echo "If the last line is 'Wails bridge initialized' and nothing follows,"
|
||||||
|
echo "the Go side reached main() and left it. There will be no panic and"
|
||||||
|
echo "no tombstone, because that is os.Exit, not a crash. Go's stdout"
|
||||||
|
echo "does not reach logcat, so the slog line naming the error is gone."
|
||||||
|
echo "Work backwards through main()'s os.Exit(1) paths instead."
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
setup) cmd_setup ;;
|
||||||
|
start) cmd_start ;;
|
||||||
|
stop) cmd_stop ;;
|
||||||
|
install) cmd_install ;;
|
||||||
|
launch) cmd_launch ;;
|
||||||
|
logs) cmd_logs ;;
|
||||||
|
screenshot) cmd_screenshot "${2:-}" ;;
|
||||||
|
inspect) cmd_inspect ;;
|
||||||
|
smoke) cmd_smoke "${2:-10}" ;;
|
||||||
|
*)
|
||||||
|
echo "usage: $0 {setup|start|stop|install|launch|logs|screenshot [path]|inspect|smoke [seconds]}" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Evaluate an expression inside the app's WebView on a real device.
|
||||||
|
*
|
||||||
|
* The device tier could only ever *look* at the app (a screenshot) or
|
||||||
|
* read what Go chose to log. This is the third thing: the page's own
|
||||||
|
* answer, from the engine that is actually rendering it — which is how
|
||||||
|
* "the icons are missing" stops being a guess about assets and becomes a
|
||||||
|
* computed style.
|
||||||
|
*
|
||||||
|
* Two facts make it work at all. A `debuggable` build calls
|
||||||
|
* `WebView.setWebContentsDebuggingEnabled(true)`, which opens an abstract
|
||||||
|
* unix socket per process (`webview_devtools_remote_<pid>`); `make
|
||||||
|
* android-inspect` forwards it to localhost. And **Playwright cannot use
|
||||||
|
* it** — `connectOverCDP` immediately calls `Browser.setDownloadBehavior`,
|
||||||
|
* which a WebView answers with "Browser context management is not
|
||||||
|
* supported", so the connection dies before the first evaluate. Raw CDP
|
||||||
|
* over Node's built-in WebSocket is a dozen lines and has no such
|
||||||
|
* opinion.
|
||||||
|
*
|
||||||
|
* Usage: node scripts/android-eval.mjs '<js expression>'
|
||||||
|
* make android-eval EXPR='...'
|
||||||
|
*
|
||||||
|
* The expression is evaluated with `awaitPromise`, so an async probe is
|
||||||
|
* fine. Return a string (`JSON.stringify(...)`) for anything structured:
|
||||||
|
* `returnByValue` will not serialise a DOM node.
|
||||||
|
*/
|
||||||
|
const PORT = process.env.YJ_ANDROID_CDP_PORT ?? '9222';
|
||||||
|
const expression = process.argv[2];
|
||||||
|
|
||||||
|
if (!expression) {
|
||||||
|
console.error("usage: node scripts/android-eval.mjs '<js expression>'");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = `http://localhost:${PORT}/json`;
|
||||||
|
let targets;
|
||||||
|
|
||||||
|
try {
|
||||||
|
targets = await (await fetch(endpoint)).json();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`android-eval: nothing on :${PORT} (${err.message})\n` +
|
||||||
|
" run 'make android-inspect' first, and check the phone is " +
|
||||||
|
'awake -- wireless adb drops when the screen sleeps',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = targets.find((t) => t.type === 'page');
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
console.error('android-eval: no page target; is the app in the foreground?');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
ws.onopen = resolve;
|
||||||
|
ws.onerror = () => reject(new Error('websocket refused'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const answer = await new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error('evaluate timed out')), 20_000);
|
||||||
|
|
||||||
|
ws.onmessage = (m) => {
|
||||||
|
const msg = JSON.parse(m.data);
|
||||||
|
|
||||||
|
if (msg.id !== 1) return;
|
||||||
|
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(msg.result);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
id: 1,
|
||||||
|
method: 'Runtime.evaluate',
|
||||||
|
params: { expression, awaitPromise: true, returnByValue: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
|
||||||
|
if (answer.exceptionDetails) {
|
||||||
|
console.error(
|
||||||
|
'android-eval: threw:',
|
||||||
|
answer.exceptionDetails.exception?.description ??
|
||||||
|
answer.exceptionDetails.text,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = answer.result?.value;
|
||||||
|
|
||||||
|
console.log(typeof value === 'string' ? value : JSON.stringify(value, null, 2));
|
||||||
@@ -157,7 +157,22 @@ fi
|
|||||||
# YJ_TESTCTL mounts backend/testctl's /__test/ endpoints. It is opt-in
|
# YJ_TESTCTL mounts backend/testctl's /__test/ endpoints. It is opt-in
|
||||||
# rather than implied by the dev build so that a human's `make dev` does
|
# rather than implied by the dev build so that a human's `make dev` does
|
||||||
# not carry an arbitrary-SQL endpoint on a listening port.
|
# not carry an arbitrary-SQL endpoint on a listening port.
|
||||||
|
#
|
||||||
|
# YJ_CORE_INDEX_URL points at a dead address, which is what
|
||||||
|
# seed-sandbox.sh and ci.yml already do and what this script was the
|
||||||
|
# only one *not* doing. Without it the app downloads and builds the
|
||||||
|
# real ~1M-row Explore catalog into the run's YJ_HOME, so a local `make
|
||||||
|
# e2e` runs against a different world than CI: the specs that stage
|
||||||
|
# their own catalog rows (requested-badge) then search a catalog full
|
||||||
|
# of real albums, fail to find their fixture, and report it as a
|
||||||
|
# regression in whatever was last changed. A spec tier whose result
|
||||||
|
# depends on what a previous run downloaded is not a result -- the same
|
||||||
|
# rule as the emulator's `-no-snapshot`.
|
||||||
|
#
|
||||||
|
# Set YJ_CORE_INDEX_URL yourself to opt back in, for exploring Explore
|
||||||
|
# by hand.
|
||||||
YJ_TESTCTL=1 \
|
YJ_TESTCTL=1 \
|
||||||
|
YJ_CORE_INDEX_URL="${YJ_CORE_INDEX_URL:-http://127.0.0.1:1/none.tar.zst}" \
|
||||||
WAILS_SERVER_PORT="$PORT" \
|
WAILS_SERVER_PORT="$PORT" \
|
||||||
YJ_LOG_LEVEL="$LOG_LEVEL" setsid dbus-run-session -- \
|
YJ_LOG_LEVEL="$LOG_LEVEL" setsid dbus-run-session -- \
|
||||||
"$BIN" \
|
"$BIN" \
|
||||||
|
|||||||