From 48b37f6301f83a48686f395b81cb43ba5af7e0f0 Mon Sep 17 00:00:00 2001 From: Logan Date: Sun, 16 Aug 2026 15:30:13 -0400 Subject: [PATCH 01/26] 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. --- .gitignore | 26 +- .golangci.yml | 11 + Taskfile.yml | 1 + build/android/Taskfile.yml | 464 ++++++ build/android/app/build.gradle | 83 + build/android/app/proguard-rules.pro | 12 + .../android/app/src/main/AndroidManifest.xml | 63 + .../main/java/com/wails/app/MainActivity.java | 821 ++++++++++ .../main/java/com/wails/app/WailsBridge.java | 1348 +++++++++++++++++ .../com/wails/app/WailsForegroundService.java | 74 + .../java/com/wails/app/WailsJSBridge.java | 151 ++ .../java/com/wails/app/WailsPathHandler.java | 68 + .../app/src/main/res/layout/activity_main.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 2338 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 2338 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 1652 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 1652 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 3276 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 3276 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 5202 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 5202 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 7198 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 7198 bytes .../app/src/main/res/values/colors.xml | 8 + .../app/src/main/res/values/strings.xml | 4 + .../app/src/main/res/values/themes.xml | 14 + .../app/src/main/res/xml/file_paths.xml | 6 + build/android/build.gradle | 4 + build/android/gradle.properties | 26 + .../android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45633 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + build/android/gradlew | 248 +++ build/android/gradlew.bat | 93 ++ build/android/main_android.go | 11 + build/android/scripts/deps/install_deps.go | 266 ++++ build/android/settings.gradle | 18 + 36 files changed, 3833 insertions(+), 6 deletions(-) create mode 100644 build/android/Taskfile.yml create mode 100644 build/android/app/build.gradle create mode 100644 build/android/app/proguard-rules.pro create mode 100644 build/android/app/src/main/AndroidManifest.xml create mode 100644 build/android/app/src/main/java/com/wails/app/MainActivity.java create mode 100644 build/android/app/src/main/java/com/wails/app/WailsBridge.java create mode 100644 build/android/app/src/main/java/com/wails/app/WailsForegroundService.java create mode 100644 build/android/app/src/main/java/com/wails/app/WailsJSBridge.java create mode 100644 build/android/app/src/main/java/com/wails/app/WailsPathHandler.java create mode 100644 build/android/app/src/main/res/layout/activity_main.xml create mode 100644 build/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 build/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 build/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 build/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 build/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 build/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 build/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 build/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 build/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 build/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 build/android/app/src/main/res/values/colors.xml create mode 100644 build/android/app/src/main/res/values/strings.xml create mode 100644 build/android/app/src/main/res/values/themes.xml create mode 100644 build/android/app/src/main/res/xml/file_paths.xml create mode 100644 build/android/build.gradle create mode 100644 build/android/gradle.properties create mode 100644 build/android/gradle/wrapper/gradle-wrapper.jar create mode 100644 build/android/gradle/wrapper/gradle-wrapper.properties create mode 100755 build/android/gradlew create mode 100644 build/android/gradlew.bat create mode 100644 build/android/main_android.go create mode 100644 build/android/scripts/deps/install_deps.go create mode 100644 build/android/settings.gradle diff --git a/.gitignore b/.gitignore index 750e605..f9b2c5e 100644 --- a/.gitignore +++ b/.gitignore @@ -63,10 +63,24 @@ bin/ # packaging tasks that depend on it. A derived file with one source. build/linux/yellowjacket.desktop -# `wails3 task common:update:build-assets` regenerates the mobile trees -# whether or not anything asks for them. This is a desktop player and -# cannot target iOS/Android, so their includes: entries are dropped from -# Taskfile.yml and the trees themselves are not carried — ignored rather -# than deleted-and-rediscovered on every asset refresh. +# iOS is not carried. `wails3 update build-assets` regenerates the tree +# whether or not anything asks for it, so it is ignored rather than +# deleted-and-rediscovered on every asset refresh, and its includes: +# entry is dropped from Taskfile.yml. +# +# build/android/ *is* carried — see plan 015. Note that `update +# build-assets` does NOT regenerate it (only `generate build-assets` +# does, and that rewrites the whole of build/), so the tree is committed +# and edited by hand like any other source. Only its output is ignored, +# below. build/ios/ -build/android/ + +# Android build output. jniLibs holds the ~30 MB per-ABI c-shared +# libraries the Go build produces; gen/ and overlay.json are written by +# `wails3 android overlay:gen`; the rest is Gradle's. +build/android/app/src/main/jniLibs/ +build/android/app/build/ +build/android/build/ +build/android/.gradle/ +build/android/gen/ +build/android/overlay.json diff --git a/.golangci.yml b/.golangci.yml index 68516b8..77edcb6 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -29,6 +29,17 @@ linters: - usetesting - whitespace - wsl_v5 + exclusions: + paths: + # Wails scaffold, not ours. `build/android/` is generated by + # `wails3 generate build-assets` and carried verbatim (plan 015), + # and it contains one Go file -- scripts/deps/install_deps.go, the + # interactive SDK installer behind `task android:install:deps`. + # It trips 24 of the strict linters above, and reformatting + # upstream's file to our house style would be undone by the next + # refresh and would make the diff against upstream unreadable. + # `make android-setup` is what this repo uses instead. + - build/android/ formatters: enable: - gci diff --git a/Taskfile.yml b/Taskfile.yml index e32e485..c8ce242 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -15,6 +15,7 @@ includes: windows: ./build/windows/Taskfile.yml darwin: ./build/darwin/Taskfile.yml linux: ./build/linux/Taskfile.yml + android: ./build/android/Taskfile.yml tasks: build: diff --git a/build/android/Taskfile.yml b/build/android/Taskfile.yml new file mode 100644 index 0000000..980b700 --- /dev/null +++ b/build/android/Taskfile.yml @@ -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//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 2>&1 &" >/dev/null 2>&1 + + # Wait for emulator to boot (max 120 seconds) + echo "Waiting for emulator to boot..." + "{{.ADB}}" wait-for-device + + for i in $(seq 1 120); do + BOOT_COMPLETED=$("{{.ADB}}" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') + if [ "$BOOT_COMPLETED" = "1" ]; then + echo "Emulator booted successfully" + exit 0 + fi + sleep 1 + done + + echo "Emulator boot timeout" + exit 1 + preconditions: + - sh: '[ -x "{{.ADB}}" ] || command -v adb' + msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)" + - sh: '[ -x "{{.EMULATOR}}" ] || command -v emulator' + msg: "emulator not found. Install the Android SDK emulator package (or set ANDROID_HOME)" + + deploy-emulator: + summary: Deploy the packaged release APK to the Android Emulator + deps: + - task: package + vars: + ARCH: '{{.ARCH | default .HOST_ARCH}}' + cmds: + - task: ensure-emulator + - '"{{.ADB}}" uninstall {{.APP_ID}} 2>/dev/null || true' + - '"{{.ADB}}" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"' + - '"{{.ADB}}" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity' + + run: + summary: Build, install and launch a debug build in the Android Emulator + deps: + - task: ensure-emulator + - task: build + cmds: + - task: assemble:apk + - '"{{.ADB}}" uninstall {{.APP_ID}} 2>/dev/null || true' + - '"{{.ADB}}" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"' + - '"{{.ADB}}" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity' + + device:list: + summary: Lists connected Android devices and emulators (serials) + cmds: + - '"{{.ADB}}" devices -l' + + run:device: + summary: Build, install and launch a debug build on a connected physical Android device + deps: + - task: build + vars: + ARCH: arm64 + cmds: + - task: assemble:apk + - | + DEVICE='{{.DEVICE_ID | default ""}}' + if [ -z "$DEVICE" ]; then + DEVICE="${DEVICE_ID:-}" + fi + if [ -z "$DEVICE" ]; then + DEVICE=$("{{.ADB}}" devices | awk 'NR > 1 && $2 == "device" && $1 !~ /^emulator-/ { print $1; exit }') + fi + if [ -z "$DEVICE" ]; then + echo "Error: no connected physical Android device found." + echo "Pass DEVICE_ID= to target a device explicitly." + echo "Find connected device serials with: {{.ADB}} devices" + exit 1 + fi + + echo "Deploying {{.BIN_DIR}}/{{.APP_NAME}}.apk to device $DEVICE..." + "{{.ADB}}" -s "$DEVICE" uninstall {{.APP_ID}} 2>/dev/null || true + "{{.ADB}}" -s "$DEVICE" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk" + "{{.ADB}}" -s "$DEVICE" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity + preconditions: + - sh: '[ -x "{{.ADB}}" ] || command -v adb' + msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)" + + deploy-device: + summary: Deploy the packaged release APK to a connected physical Android device + deps: + - task: package + vars: + ARCH: arm64 + cmds: + - | + DEVICE='{{.DEVICE_ID | default ""}}' + if [ -z "$DEVICE" ]; then + DEVICE="${DEVICE_ID:-}" + fi + if [ -z "$DEVICE" ]; then + DEVICE=$("{{.ADB}}" devices | awk 'NR > 1 && $2 == "device" && $1 !~ /^emulator-/ { print $1; exit }') + fi + if [ -z "$DEVICE" ]; then + echo "Error: no connected physical Android device found." + echo "Pass DEVICE_ID= to target a device explicitly." + echo "Find connected device serials with: {{.ADB}} devices" + exit 1 + fi + + echo "Deploying {{.BIN_DIR}}/{{.APP_NAME}}.apk to device $DEVICE..." + "{{.ADB}}" -s "$DEVICE" uninstall {{.APP_ID}} 2>/dev/null || true + "{{.ADB}}" -s "$DEVICE" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk" + "{{.ADB}}" -s "$DEVICE" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity + preconditions: + - sh: '[ -x "{{.ADB}}" ] || command -v adb' + msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)" + + studio: + summary: Open the generated Android project in Android Studio + cmds: + - | + if command -v studio >/dev/null 2>&1; then + studio build/android + elif [ -d "/Applications/Android Studio.app" ]; then + open -a "Android Studio" build/android + else + echo "Android Studio not found. Install it from https://developer.android.com/studio," + echo "then open the build/android directory." + exit 1 + fi + + logs: + summary: Stream Android logcat filtered to this app + cmds: + - '"{{.ADB}}" logcat -v time | grep -E "(Wails|{{.APP_NAME}})" || true' + + logs:all: + summary: Stream all Android logcat (verbose) + cmds: + - '"{{.ADB}}" logcat -v time' + + clean: + summary: Clean build artifacts + cmds: + - rm -rf {{.BIN_DIR}} + - rm -rf build/android/app/build + - rm -rf build/android/app/src/main/jniLibs/*/libwails.so + - rm -rf build/android/.gradle diff --git a/build/android/app/build.gradle b/build/android/app/build.gradle new file mode 100644 index 0000000..9c69943 --- /dev/null +++ b/build/android/app/build.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' +} diff --git a/build/android/app/proguard-rules.pro b/build/android/app/proguard-rules.pro new file mode 100644 index 0000000..8b88c3d --- /dev/null +++ b/build/android/app/proguard-rules.pro @@ -0,0 +1,12 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. + +# Keep native methods +-keepclasseswithmembernames class * { + native ; +} + +# Keep Wails bridge classes +-keep class com.wails.app.WailsBridge { *; } +-keep class com.wails.app.WailsJSBridge { *; } diff --git a/build/android/app/src/main/AndroidManifest.xml b/build/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..03d343a --- /dev/null +++ b/build/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build/android/app/src/main/java/com/wails/app/MainActivity.java b/build/android/app/src/main/java/com/wails/app/MainActivity.java new file mode 100644 index 0000000..7b71d3c --- /dev/null +++ b/build/android/app/src/main/java/com/wails/app/MainActivity.java @@ -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 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