Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7a33527c4 | ||
|
|
0c6ca72cf1 | ||
|
|
68468e5378 | ||
|
|
6fbb62730d | ||
|
|
48b37f6301 |
No files matched your search
@@ -0,0 +1,362 @@
|
|||||||
|
name: Build & publish the Android APK
|
||||||
|
|
||||||
|
# The fifth workflow, and the second that publishes. It builds a signed
|
||||||
|
# fat APK (arm64-v8a + x86_64) on every version tag and puts it in
|
||||||
|
# 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.
|
||||||
|
- name: Decode the signing keystore
|
||||||
|
env:
|
||||||
|
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
|
||||||
|
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
|
||||||
|
# The path is decided here and exported, never composed in a
|
||||||
|
# later step's `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 into the build.
|
||||||
|
keystore="${RUNNER_TEMP:-/tmp}/yellowjacket-release.jks"
|
||||||
|
printf '%s' "$KEYSTORE_B64" | base64 -d > "$keystore"
|
||||||
|
chmod 600 "$keystore"
|
||||||
|
echo "ANDROID_KEYSTORE_FILE=$keystore" >> "$GITHUB_ENV"
|
||||||
|
echo "keystore decoded ($(stat -c %s "$keystore") bytes)"
|
||||||
|
|
||||||
|
# **There is one password and two required secrets.** keytool has
|
||||||
|
# defaulted to PKCS12 since JDK 9 — the .jks extension does not
|
||||||
|
# change that — and PKCS12 cannot hold a separate key password:
|
||||||
|
# given -keypass it prints "Different store and key passwords not
|
||||||
|
# supported for PKCS12 KeyStores. Ignoring user-specified -keypass
|
||||||
|
# value." (confirmed verbatim). So the key password defaults to
|
||||||
|
# the store password and the alias to the documented one. Asking
|
||||||
|
# for a second password that cannot exist is how someone sets a
|
||||||
|
# wrong value and debugs Gradle at midnight.
|
||||||
|
- name: Build the fat APK
|
||||||
|
working-directory: /src
|
||||||
|
env:
|
||||||
|
YJ_VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
YJ_VERSION_CODE: ${{ steps.version.outputs.code }}
|
||||||
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
if [ -z "${ANDROID_KEYSTORE_PASSWORD:-}" ]; then
|
||||||
|
echo "ANDROID_KEYSTORE_PASSWORD is not set" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check the keystore before Gradle does. Gradle only notices
|
||||||
|
# at :app:validateSigningRelease — a minute of build time in —
|
||||||
|
# and reports it as a missing file rather than a bad password.
|
||||||
|
if [ ! -s "${ANDROID_KEYSTORE_FILE:-}" ]; then
|
||||||
|
echo "keystore missing at '${ANDROID_KEYSTORE_FILE:-<unset>}'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
keytool -list -keystore "$ANDROID_KEYSTORE_FILE" \
|
||||||
|
-storepass "$ANDROID_KEYSTORE_PASSWORD" >/dev/null || {
|
||||||
|
echo "the keystore did not open — is ANDROID_KEYSTORE_PASSWORD right?" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo "keystore opens with the supplied password"
|
||||||
|
|
||||||
|
export ANDROID_KEY_ALIAS="${KEY_ALIAS:-yellowjacket}"
|
||||||
|
export ANDROID_KEY_PASSWORD="${KEY_PASSWORD:-$ANDROID_KEYSTORE_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"
|
||||||
|
|
||||||
|
# **Nothing here pipes into `head`.** Under `set -o pipefail`,
|
||||||
|
# `head -1` exits after one line, the producer takes SIGPIPE and
|
||||||
|
# the pipeline fails with 141 — so in ljos this step failed
|
||||||
|
# *after* printing a correctly signed APK. `-print -quit` and a
|
||||||
|
# captured variable have no second process to kill.
|
||||||
|
- 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'
|
||||||
|
|
||||||
|
# Both ABIs, or the artifact is not the fat APK it claims to be.
|
||||||
|
"$bt/aapt2" dump badging "$apk" | grep -q "native-code: 'arm64-v8a' 'x86_64'" || {
|
||||||
|
echo "the APK does not carry both ABIs" >&2; exit 1; }
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -151,6 +151,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 +427,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,169 @@
|
|||||||
|
# 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 # fat APK (arm64 + x86_64) -> bin/yellowjacket.apk
|
||||||
|
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`.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
`make android-smoke` **fails today, and the cause is known.**
|
||||||
|
`backend/system`'s `buildUserDirPath` switches on `runtime.GOOS` with
|
||||||
|
cases for darwin, linux and windows, and a `default:` that returns
|
||||||
|
`errUnsupportedOS`. `runtime.GOOS` is `"android"`, so it takes the
|
||||||
|
default, `NewYellowJacketApp` fails, and `main()` calls `os.Exit(1)` —
|
||||||
|
about 6 ms after the bridge initialises, which is exactly the signature
|
||||||
|
described above.
|
||||||
|
|
||||||
|
**The fix is a documented Wails API and needs no build tags.**
|
||||||
|
`application.Mobile.StoragePath()` returns the app's private files
|
||||||
|
directory and returns `""` on desktop (`mobile_stub.go`), and
|
||||||
|
`resolveUserDirPath` already lets `YJ_HOME` override the path on every
|
||||||
|
OS — so setting that override from `StoragePath()` early in `main()`,
|
||||||
|
when it is non-empty, is the whole change. Do *not* import
|
||||||
|
`pkg/application` into `backend/system`: that package is deliberately
|
||||||
|
Wails-free, which is what the `indexbuild` tag split is protecting.
|
||||||
|
|
||||||
|
It is the *first* thing that stops it, not the only one. MPRIS is
|
||||||
|
compiled in (`android` implies the `linux` build tag, so
|
||||||
|
`mpris_linux.go` is in the build and will look for a session bus that
|
||||||
|
does not exist), and the desktop shell is still a desktop shell. Fixing
|
||||||
|
one and re-running the smoke is how you find the next.
|
||||||
|
|
||||||
|
**And one that no amount of porting will fix:** open-*directory*
|
||||||
|
dialogs return an error on Android — the Storage Access Framework gives
|
||||||
|
tree URIs, not filesystem paths — as do save-file dialogs. This app's
|
||||||
|
first run is "choose your music folder" and its library model is
|
||||||
|
filesystem paths, so that is a design question, not a port.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -2394,3 +2394,196 @@ 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.
|
||||||
@@ -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.
|
||||||
@@ -1768,10 +1768,24 @@ 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 fat APK
|
||||||
|
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:
|
||||||
|
|
||||||
@@ -1853,11 +1867,20 @@ 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.
|
||||||
|
`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,46 @@ 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)
|
||||||
|
|
||||||
|
android: build-frontend ## Build the fat APK (arm64 + x86_64) into bin/
|
||||||
|
@$(ANDROID_ENV) PATH="$(TOOLBIN):$$PATH" go tool wails3 task android:package:fat
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# "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:
|
||||||
|
|||||||
@@ -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,106 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
// Configure supported ABIs
|
||||||
|
ndk {
|
||||||
|
abiFilters 'arm64-v8a', 'x86_64'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
doNotStrip '*/x86_64/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,63 @@
|
|||||||
|
<?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" />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".WailsForegroundService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,821 @@
|
|||||||
|
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.os.BatteryManager;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.PowerManager;
|
||||||
|
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 androidx.annotation.Nullable;
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
import androidx.core.content.FileProvider;
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Initialize the native Go library
|
||||||
|
bridge = new WailsBridge(this);
|
||||||
|
bridge.initialize();
|
||||||
|
|
||||||
|
// Set up WebView
|
||||||
|
setupWebView();
|
||||||
|
|
||||||
|
// Load the application
|
||||||
|
loadApplication();
|
||||||
|
}
|
||||||
|
|
||||||
|
@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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBackPressed() {
|
||||||
|
if (webView != null && webView.canGoBack()) {
|
||||||
|
webView.goBack();
|
||||||
|
} else {
|
||||||
|
super.onBackPressed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.wails.app;
|
||||||
|
|
||||||
|
import android.app.Notification;
|
||||||
|
import android.app.NotificationChannel;
|
||||||
|
import android.app.NotificationManager;
|
||||||
|
import android.app.PendingIntent;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.pm.ServiceInfo;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.IBinder;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.core.app.NotificationCompat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A minimal started foreground service. It does no work of its own — its purpose
|
||||||
|
* is to keep the app's process alive (with the required ongoing notification) so
|
||||||
|
* the developer's Go goroutines keep running while the app is backgrounded,
|
||||||
|
* which Android would otherwise be free to kill. Start it from
|
||||||
|
* {@link WailsBridge#startForegroundService(String)} and stop it with
|
||||||
|
* {@link WailsBridge#stopForegroundService()}.
|
||||||
|
*/
|
||||||
|
public class WailsForegroundService extends android.app.Service {
|
||||||
|
public static final String ACTION_START = "com.wails.app.FGS_START";
|
||||||
|
private static final String CHANNEL_ID = "wails_foreground";
|
||||||
|
private static final int NOTIFICATION_ID = 0x57A1; // "WAI"
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||||
|
String title = "Wails";
|
||||||
|
String text = "Running in the background";
|
||||||
|
if (intent != null) {
|
||||||
|
if (intent.getStringExtra("title") != null) title = intent.getStringExtra("title");
|
||||||
|
if (intent.getStringExtra("text") != null) text = intent.getStringExtra("text");
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
NotificationChannel ch = new NotificationChannel(
|
||||||
|
CHANNEL_ID, "Background work", NotificationManager.IMPORTANCE_LOW);
|
||||||
|
nm.createNotificationChannel(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
PendingIntent contentIntent = null;
|
||||||
|
Intent launch = getPackageManager().getLaunchIntentForPackage(getPackageName());
|
||||||
|
if (launch != null) {
|
||||||
|
int piFlags = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
|
||||||
|
? PendingIntent.FLAG_IMMUTABLE : 0;
|
||||||
|
contentIntent = PendingIntent.getActivity(this, 0, launch, piFlags);
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification n = new NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_popup_sync)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(text)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setContentIntent(contentIntent)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(NOTIFICATION_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIFICATION_ID, n);
|
||||||
|
}
|
||||||
|
// Restart if the OS kills us while still wanted.
|
||||||
|
return START_STICKY;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="wails_blue">#3574D4</color>
|
||||||
|
<color name="wails_blue_dark">#2C5FB8</color>
|
||||||
|
<color name="wails_background">#1B2636</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,143 @@
|
|||||||
|
# Releasing the Android APK
|
||||||
|
|
||||||
|
`.gitea/workflows/android-apk.yml` builds a signed fat APK
|
||||||
|
(`arm64-v8a` + `x86_64`) on every `v*` tag and publishes it to Gitea's
|
||||||
|
**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 **both** ABIs (`native-code: 'arm64-v8a' 'x86_64'`), or it
|
||||||
|
is not the fat APK it claims to be;
|
||||||
|
- 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,235 @@
|
|||||||
|
#!/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}"
|
||||||
|
# **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'"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
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"
|
||||||
|
"$ADB" get-state >/dev/null 2>&1 || die "no device — run 'make android-emulator' first"
|
||||||
|
"$ADB" install -r bin/yellowjacket.apk
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_launch() {
|
||||||
|
need_sdk
|
||||||
|
"$ADB" shell am force-stop "$PKG"
|
||||||
|
"$ADB" logcat -c
|
||||||
|
"$ADB" shell am start -n "$PKG/$ACTIVITY" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_logs() {
|
||||||
|
need_sdk
|
||||||
|
# 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'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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
|
||||||
|
local first
|
||||||
|
first=$("$ADB" shell pidof "$PKG" 2>/dev/null | tr -d '\r' | awk '{print $1}')
|
||||||
|
sleep "$wait_s"
|
||||||
|
local second
|
||||||
|
second=$("$ADB" shell pidof "$PKG" 2>/dev/null | tr -d '\r' | awk '{print $1}')
|
||||||
|
|
||||||
|
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 ;;
|
||||||
|
smoke) cmd_smoke "${2:-10}" ;;
|
||||||
|
*)
|
||||||
|
echo "usage: $0 {setup|start|stop|install|launch|logs|smoke [seconds]}" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||