This commit is contained in:
root
2026-07-13 13:02:23 +00:00
parent ce111431c3
commit 2445995172
111 changed files with 11646 additions and 0 deletions
+290
View File
@@ -0,0 +1,290 @@
version: '3'
includes:
common: ../Taskfile.yml
vars:
APP_ID: '{{.APP_ID | default "com.wails.app"}}'
MIN_SDK: '21'
TARGET_SDK: '34'
# 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"'
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
deps:
- task: build
vars:
PRODUCTION: "true"
ARCH: '{{.ARCH | default .HOST_ARCH}}'
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
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"
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
echo "No Android Virtual Devices found. Create one with:"
echo " avdmanager create avd --name wails --package 'system-images;android-34;google_apis;{{.ANDROID_ABI}}' --device pixel_7"
exit 1
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: [package]
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'
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
+83
View File
@@ -0,0 +1,83 @@
plugins {
id 'com.android.application'
}
android {
namespace 'com.wails.app'
compileSdk 34
buildFeatures {
buildConfig = true
}
defaultConfig {
applicationId "com.wails.app"
minSdk 21
targetSdk 34
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'
}
+12
View File
@@ -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,806 @@
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) {
requestPermissions(new String[]{"android.permission.CAMERA"}, CAMERA_PERMISSION_REQUEST);
bridge.emitEvent("common:capture",
"{\"error\":\"camera permission requested \u2014 tap again once granted\"}");
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\"}");
}
}
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 &lt;video&gt; 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();
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

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>
+4
View File
@@ -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
}
+26
View File
@@ -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
Binary file not shown.
@@ -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
+248
View File
@@ -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" "$@"
+93
View File
@@ -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
+11
View File
@@ -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)
}
+151
View File
@@ -0,0 +1,151 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"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 {
fmt.Println("⚠ No Android Virtual Devices found. Create one via Android Studio > Tools > Device Manager")
}
}
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 34)")
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
}
+18
View File
@@ -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'