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