build(android): carry the Wails Android scaffolding verbatim
Plan 015 phase 0 established that this app cross-compiles for Android with no source changes at all. A CGO_ENABLED=0 probe of the whole tree for android/arm64 fails on exactly two packages -- ebitengine/oto/v3 and wails/v3/pkg/application -- and both fail only because their Android implementation is cgo, which is what the NDK supplies. Notably modernc.org/sqlite, the entire database layer and the thing most likely to have no Android target, is clean. The fat APK (arm64-v8a + x86_64) builds in about 25 seconds. So build/android/ stops being ignored. This commit is the tree exactly as `wails3 generate build-assets` emits it, so that the next commit is a readable diff of what we changed and a future refresh has something to compare against. Two things about how it is carried: `wails3 update build-assets` does NOT generate it, contrary to what CLAUDE.md has claimed since the v3 migration. In beta.8 that command extracts only internal/commands/updatable_build_assets, which is darwin/ios/linux/windows; the android tree comes from `generate build-assets`, which rewrites the whole of build/. It was generated once into a scratch directory and copied across, so from here it is committed and hand-edited like source. Only its output is ignored -- jniLibs (~60MB of per-ABI c-shared libraries), gen/, overlay.json and Gradle's own directories. And it brings one Go file into ./... -- scripts/deps/install_deps.go, the interactive SDK installer behind `task android:install:deps`, which trips 24 of our strict linters. golangci excludes the directory rather than reformatting upstream's file, which the next refresh would undo and which would make the diff against upstream unreadable. This repo uses `make android-setup` instead.
@@ -63,10 +63,24 @@ bin/
|
||||
# packaging tasks that depend on it. A derived file with one source.
|
||||
build/linux/yellowjacket.desktop
|
||||
|
||||
# `wails3 task common:update:build-assets` regenerates the mobile trees
|
||||
# whether or not anything asks for them. This is a desktop player and
|
||||
# cannot target iOS/Android, so their includes: entries are dropped from
|
||||
# Taskfile.yml and the trees themselves are not carried — ignored rather
|
||||
# than deleted-and-rediscovered on every asset refresh.
|
||||
# iOS is not carried. `wails3 update build-assets` regenerates the tree
|
||||
# whether or not anything asks for it, so it is ignored rather than
|
||||
# deleted-and-rediscovered on every asset refresh, and its includes:
|
||||
# entry is dropped from Taskfile.yml.
|
||||
#
|
||||
# build/android/ *is* carried — see plan 015. Note that `update
|
||||
# build-assets` does NOT regenerate it (only `generate build-assets`
|
||||
# does, and that rewrites the whole of build/), so the tree is committed
|
||||
# and edited by hand like any other source. Only its output is ignored,
|
||||
# below.
|
||||
build/ios/
|
||||
build/android/
|
||||
|
||||
# Android build output. jniLibs holds the ~30 MB per-ABI c-shared
|
||||
# libraries the Go build produces; gen/ and overlay.json are written by
|
||||
# `wails3 android overlay:gen`; the rest is Gradle's.
|
||||
build/android/app/src/main/jniLibs/
|
||||
build/android/app/build/
|
||||
build/android/build/
|
||||
build/android/.gradle/
|
||||
build/android/gen/
|
||||
build/android/overlay.json
|
||||
|
||||
@@ -29,6 +29,17 @@ linters:
|
||||
- usetesting
|
||||
- whitespace
|
||||
- wsl_v5
|
||||
exclusions:
|
||||
paths:
|
||||
# Wails scaffold, not ours. `build/android/` is generated by
|
||||
# `wails3 generate build-assets` and carried verbatim (plan 015),
|
||||
# and it contains one Go file -- scripts/deps/install_deps.go, the
|
||||
# interactive SDK installer behind `task android:install:deps`.
|
||||
# It trips 24 of the strict linters above, and reformatting
|
||||
# upstream's file to our house style would be undone by the next
|
||||
# refresh and would make the diff against upstream unreadable.
|
||||
# `make android-setup` is what this repo uses instead.
|
||||
- build/android/
|
||||
formatters:
|
||||
enable:
|
||||
- gci
|
||||
|
||||
@@ -15,6 +15,7 @@ includes:
|
||||
windows: ./build/windows/Taskfile.yml
|
||||
darwin: ./build/darwin/Taskfile.yml
|
||||
linux: ./build/linux/Taskfile.yml
|
||||
android: ./build/android/Taskfile.yml
|
||||
|
||||
tasks:
|
||||
build:
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
version: '3'
|
||||
|
||||
includes:
|
||||
common: ../Taskfile.yml
|
||||
|
||||
vars:
|
||||
APP_ID: '{{.APP_ID | default "com.wails.app"}}'
|
||||
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}}'
|
||||
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,83 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'com.wails.app'
|
||||
compileSdk 35
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.wails.app"
|
||||
minSdk 21
|
||||
targetSdk 35
|
||||
versionCode 1
|
||||
versionName "1.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">Wails App</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'
|
||||