Initial commit
@@ -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 <video> can stream and seek a clip of any length without
|
||||
* inlining it as a data URL.
|
||||
*/
|
||||
private WebResourceResponse serveCaptureFile(String relPath, WebResourceRequest request) {
|
||||
try {
|
||||
File cache = getCacheDir();
|
||||
File file = new File(cache, Uri.decode(relPath));
|
||||
// Path-traversal guard: only ever serve files under the cache dir.
|
||||
if (!file.getCanonicalPath().startsWith(cache.getCanonicalPath() + File.separator)
|
||||
|| !file.exists() || !file.isFile()) {
|
||||
return new WebResourceResponse("text/plain", "UTF-8", 404, "Not Found",
|
||||
new java.util.HashMap<>(), new java.io.ByteArrayInputStream(new byte[0]));
|
||||
}
|
||||
String name = file.getName().toLowerCase();
|
||||
String mime = name.endsWith(".mp4") ? "video/mp4"
|
||||
: name.endsWith(".mov") ? "video/quicktime"
|
||||
: name.endsWith(".jpg") || name.endsWith(".jpeg") ? "image/jpeg"
|
||||
: name.endsWith(".png") ? "image/png" : "application/octet-stream";
|
||||
long length = file.length();
|
||||
java.util.Map<String, String> reqHeaders = request.getRequestHeaders();
|
||||
String range = reqHeaders != null ? reqHeaders.get("Range") : null;
|
||||
if (range == null && reqHeaders != null) range = reqHeaders.get("range");
|
||||
|
||||
java.util.Map<String, String> headers = new java.util.HashMap<>();
|
||||
headers.put("Accept-Ranges", "bytes");
|
||||
headers.put("Cache-Control", "no-store");
|
||||
|
||||
if (range != null && range.startsWith("bytes=")) {
|
||||
long start = 0, end = length - 1;
|
||||
String spec = range.substring(6).trim();
|
||||
int dash = spec.indexOf('-');
|
||||
if (dash >= 0) {
|
||||
try {
|
||||
if (dash > 0) start = Long.parseLong(spec.substring(0, dash).trim());
|
||||
String e = spec.substring(dash + 1).trim();
|
||||
if (!e.isEmpty()) end = Long.parseLong(e);
|
||||
} catch (NumberFormatException ignored) { }
|
||||
}
|
||||
if (start < 0) start = 0;
|
||||
if (end >= length) end = length - 1;
|
||||
if (start > end) { start = 0; end = length - 1; }
|
||||
long count = end - start + 1;
|
||||
java.io.InputStream in = new java.io.FileInputStream(file);
|
||||
long toSkip = start;
|
||||
while (toSkip > 0) {
|
||||
long s = in.skip(toSkip);
|
||||
if (s <= 0) break;
|
||||
toSkip -= s;
|
||||
}
|
||||
headers.put("Content-Range", "bytes " + start + "-" + end + "/" + length);
|
||||
headers.put("Content-Length", String.valueOf(count));
|
||||
return new WebResourceResponse(mime, null, 206, "Partial Content",
|
||||
headers, new LimitedInputStream(in, count));
|
||||
}
|
||||
headers.put("Content-Length", String.valueOf(length));
|
||||
return new WebResourceResponse(mime, null, 200, "OK", headers,
|
||||
new java.io.FileInputStream(file));
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "serveCaptureFile failed", e);
|
||||
return new WebResourceResponse("text/plain", "UTF-8", 500, "Error",
|
||||
new java.util.HashMap<>(), new java.io.ByteArrayInputStream(new byte[0]));
|
||||
}
|
||||
}
|
||||
|
||||
/** Wraps a stream to yield at most a fixed number of bytes (for Range responses). */
|
||||
private static final class LimitedInputStream extends java.io.FilterInputStream {
|
||||
private long remaining;
|
||||
LimitedInputStream(java.io.InputStream in, long limit) {
|
||||
super(in);
|
||||
this.remaining = limit;
|
||||
}
|
||||
@Override public int read() throws java.io.IOException {
|
||||
if (remaining <= 0) return -1;
|
||||
int b = super.read();
|
||||
if (b >= 0) remaining--;
|
||||
return b;
|
||||
}
|
||||
@Override public int read(byte[] b, int off, int len) throws java.io.IOException {
|
||||
if (remaining <= 0) return -1;
|
||||
int n = super.read(b, off, (int) Math.min(len, remaining));
|
||||
if (n > 0) remaining -= n;
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the system document picker. Results are copied into the app's
|
||||
* cache directory so Go receives real filesystem paths. Called by
|
||||
* WailsBridge on the main thread.
|
||||
*/
|
||||
public void launchFilePicker(int callbackID, boolean multiple) {
|
||||
synchronized (this) {
|
||||
if (pendingFilePickerCallbackID != -1) {
|
||||
// Only one picker can be in flight
|
||||
bridge.filePickerDone(callbackID);
|
||||
return;
|
||||
}
|
||||
pendingFilePickerCallbackID = callbackID;
|
||||
}
|
||||
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("*/*");
|
||||
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multiple);
|
||||
try {
|
||||
startActivityForResult(intent, FILE_PICKER_REQUEST);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Failed to launch file picker", e);
|
||||
pendingFilePickerCallbackID = -1;
|
||||
bridge.filePickerDone(callbackID);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
if (requestCode == PHOTO_CAPTURE_REQUEST || requestCode == VIDEO_CAPTURE_REQUEST) {
|
||||
handleCaptureResult(resultCode, data);
|
||||
return;
|
||||
}
|
||||
if (requestCode != FILE_PICKER_REQUEST) {
|
||||
return;
|
||||
}
|
||||
final int callbackID = pendingFilePickerCallbackID;
|
||||
pendingFilePickerCallbackID = -1;
|
||||
if (callbackID == -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
final List<Uri> uris = new ArrayList<>();
|
||||
if (resultCode == RESULT_OK && data != null) {
|
||||
if (data.getClipData() != null) {
|
||||
for (int i = 0; i < data.getClipData().getItemCount(); i++) {
|
||||
uris.add(data.getClipData().getItemAt(i).getUri());
|
||||
}
|
||||
} else if (data.getData() != null) {
|
||||
uris.add(data.getData());
|
||||
}
|
||||
}
|
||||
|
||||
// Copy the documents off the main thread, then notify Go
|
||||
new Thread(() -> {
|
||||
for (Uri uri : uris) {
|
||||
String path = copyUriToCache(uri);
|
||||
if (path != null) {
|
||||
bridge.filePickerResult(callbackID, path);
|
||||
}
|
||||
}
|
||||
bridge.filePickerDone(callbackID);
|
||||
}).start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a content URI into the app cache and return its filesystem path.
|
||||
*/
|
||||
@Nullable
|
||||
private String copyUriToCache(Uri uri) {
|
||||
String name = "document";
|
||||
try (Cursor cursor = getContentResolver().query(uri, null, null, null, null)) {
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
int idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
||||
if (idx >= 0 && cursor.getString(idx) != null) {
|
||||
name = new File(cursor.getString(idx)).getName();
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
try {
|
||||
File dir = new File(getCacheDir(), "wails-picker/" + System.nanoTime());
|
||||
if (!dir.mkdirs()) {
|
||||
return null;
|
||||
}
|
||||
File out = new File(dir, name);
|
||||
try (InputStream in = getContentResolver().openInputStream(uri);
|
||||
OutputStream os = new FileOutputStream(out)) {
|
||||
if (in == null) {
|
||||
return null;
|
||||
}
|
||||
byte[] buf = new byte[64 * 1024];
|
||||
int n;
|
||||
while ((n = in.read(buf)) > 0) {
|
||||
os.write(buf, 0, n);
|
||||
}
|
||||
}
|
||||
return out.getAbsolutePath();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Failed to copy picked document", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute JavaScript in the WebView from the Go side
|
||||
*/
|
||||
public void executeJavaScript(final String js) {
|
||||
runOnUiThread(() -> {
|
||||
if (webView != null) {
|
||||
webView.evaluateJavascript(js, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- System events ---------------------------------------------------
|
||||
// Battery/power, screen lock and network connectivity are surfaced to JS as
|
||||
// "system:*" events. The OS broadcasts used here (ACTION_BATTERY_CHANGED,
|
||||
// SCREEN_OFF, USER_PRESENT, POWER_SAVE_MODE_CHANGED) are protected system
|
||||
// broadcasts, so dynamic registration needs no RECEIVER_* export flag.
|
||||
|
||||
private void registerSystemEventReceivers() {
|
||||
// Battery + charging state (sticky broadcast: the current value is
|
||||
// delivered to the receiver immediately on registration).
|
||||
batteryReceiver = new BroadcastReceiver() {
|
||||
@Override public void onReceive(Context context, Intent intent) {
|
||||
emitBattery(intent);
|
||||
}
|
||||
};
|
||||
registerReceiver(batteryReceiver, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
|
||||
|
||||
// Low-power (battery saver) mode toggles → re-emit battery with the flag.
|
||||
powerSaveReceiver = new BroadcastReceiver() {
|
||||
@Override public void onReceive(Context context, Intent intent) {
|
||||
emitBattery(registerSticky(Intent.ACTION_BATTERY_CHANGED));
|
||||
}
|
||||
};
|
||||
registerReceiver(powerSaveReceiver,
|
||||
new IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED));
|
||||
|
||||
// Screen lock / unlock. SCREEN_OFF ≈ locked; USER_PRESENT = unlocked.
|
||||
screenReceiver = new BroadcastReceiver() {
|
||||
@Override public void onReceive(Context context, Intent intent) {
|
||||
String action = intent.getAction();
|
||||
if (Intent.ACTION_SCREEN_OFF.equals(action)) {
|
||||
emitLock(true);
|
||||
} else if (Intent.ACTION_USER_PRESENT.equals(action)) {
|
||||
emitLock(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
IntentFilter screenFilter = new IntentFilter();
|
||||
screenFilter.addAction(Intent.ACTION_SCREEN_OFF);
|
||||
screenFilter.addAction(Intent.ACTION_USER_PRESENT);
|
||||
registerReceiver(screenReceiver, screenFilter);
|
||||
|
||||
// Network connectivity / transport type / cellular signal strength.
|
||||
connectivityManager = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
if (connectivityManager != null) {
|
||||
networkCallback = new ConnectivityManager.NetworkCallback() {
|
||||
@Override public void onAvailable(Network network) { emitNetwork(network); }
|
||||
@Override public void onLost(Network network) { emitNetworkDisconnected(); }
|
||||
@Override public void onCapabilitiesChanged(Network network, NetworkCapabilities caps) {
|
||||
emitNetwork(network);
|
||||
}
|
||||
};
|
||||
try {
|
||||
connectivityManager.registerDefaultNetworkCallback(networkCallback);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "registerDefaultNetworkCallback failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void unregisterSystemEventReceivers() {
|
||||
safeUnregister(batteryReceiver);
|
||||
batteryReceiver = null;
|
||||
safeUnregister(powerSaveReceiver);
|
||||
powerSaveReceiver = null;
|
||||
safeUnregister(screenReceiver);
|
||||
screenReceiver = null;
|
||||
if (connectivityManager != null && networkCallback != null) {
|
||||
try {
|
||||
connectivityManager.unregisterNetworkCallback(networkCallback);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
networkCallback = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void safeUnregister(BroadcastReceiver r) {
|
||||
if (r != null) {
|
||||
try {
|
||||
unregisterReceiver(r);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the current sticky value for an action without a standing receiver. */
|
||||
@Nullable
|
||||
private Intent registerSticky(String action) {
|
||||
return registerReceiver(null, new IntentFilter(action));
|
||||
}
|
||||
|
||||
/** Push current battery / network / theme so a freshly-loaded UI is populated. */
|
||||
private void emitSystemSnapshot() {
|
||||
emitBattery(registerSticky(Intent.ACTION_BATTERY_CHANGED));
|
||||
if (connectivityManager != null) {
|
||||
Network active = connectivityManager.getActiveNetwork();
|
||||
if (active != null) {
|
||||
emitNetwork(active);
|
||||
} else {
|
||||
emitNetworkDisconnected();
|
||||
}
|
||||
}
|
||||
emitTheme();
|
||||
}
|
||||
|
||||
private void emitBattery(@Nullable Intent batteryStatus) {
|
||||
try {
|
||||
float level = -1f;
|
||||
String state = "unknown";
|
||||
if (batteryStatus != null) {
|
||||
int lvl = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
|
||||
int scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1);
|
||||
if (lvl >= 0 && scale > 0) {
|
||||
level = lvl / (float) scale;
|
||||
}
|
||||
switch (batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1)) {
|
||||
case BatteryManager.BATTERY_STATUS_CHARGING: state = "charging"; break;
|
||||
case BatteryManager.BATTERY_STATUS_FULL: state = "full"; break;
|
||||
case BatteryManager.BATTERY_STATUS_DISCHARGING:
|
||||
case BatteryManager.BATTERY_STATUS_NOT_CHARGING: state = "unplugged"; break;
|
||||
default: state = "unknown"; break;
|
||||
}
|
||||
}
|
||||
boolean lowPower = false;
|
||||
PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
|
||||
if (pm != null) {
|
||||
lowPower = pm.isPowerSaveMode();
|
||||
}
|
||||
JSONObject o = new JSONObject();
|
||||
o.put("level", (double) level);
|
||||
o.put("state", state);
|
||||
o.put("lowPowerMode", lowPower);
|
||||
if (bridge != null) bridge.emitSystemEvent("android:BatteryChanged", o.toString());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "emitBattery failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void emitNetwork(@Nullable Network network) {
|
||||
try {
|
||||
boolean connected = false;
|
||||
String type = "none";
|
||||
boolean metered = false;
|
||||
Integer signal = null;
|
||||
if (connectivityManager != null && network != null) {
|
||||
NetworkCapabilities caps = connectivityManager.getNetworkCapabilities(network);
|
||||
if (caps != null) {
|
||||
connected = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET);
|
||||
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
||||
type = "wifi";
|
||||
} else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
|
||||
type = "cellular";
|
||||
} else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
|
||||
type = "wired";
|
||||
} else {
|
||||
type = "other";
|
||||
}
|
||||
metered = !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
int s = caps.getSignalStrength();
|
||||
if (s != Integer.MIN_VALUE) {
|
||||
signal = s; // dBm; closer to 0 is a stronger signal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
JSONObject o = new JSONObject();
|
||||
o.put("connected", connected);
|
||||
o.put("type", type);
|
||||
o.put("metered", metered);
|
||||
if (signal != null) {
|
||||
o.put("signal", (int) signal);
|
||||
}
|
||||
if (bridge != null) bridge.emitSystemEvent("android:NetworkChanged", o.toString());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "emitNetwork failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void emitNetworkDisconnected() {
|
||||
try {
|
||||
JSONObject o = new JSONObject();
|
||||
o.put("connected", false);
|
||||
o.put("type", "none");
|
||||
o.put("metered", false);
|
||||
if (bridge != null) bridge.emitSystemEvent("android:NetworkChanged", o.toString());
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private void emitLock(boolean locked) {
|
||||
// Lock/unlock are signals (no payload); name carries the state.
|
||||
if (bridge != null) {
|
||||
bridge.emitSystemEvent(locked ? "android:ScreenLocked" : "android:ScreenUnlocked", "{}");
|
||||
}
|
||||
}
|
||||
|
||||
private void emitTheme() {
|
||||
try {
|
||||
int mode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
|
||||
JSONObject o = new JSONObject();
|
||||
// "isDarkMode" matches the context key the desktop platforms use.
|
||||
o.put("isDarkMode", mode == Configuration.UI_MODE_NIGHT_YES);
|
||||
if (bridge != null) bridge.emitSystemEvent("android:ThemeChanged", o.toString());
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConfigurationChanged(Configuration newConfig) {
|
||||
super.onConfigurationChanged(newConfig);
|
||||
// Fires for light/dark switches because the manifest lists uiMode in
|
||||
// android:configChanges (otherwise the activity would be recreated).
|
||||
emitTheme();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
super.onStart();
|
||||
// Battery: only monitor system events while the app is visible.
|
||||
if (!systemReceiversRegistered) {
|
||||
registerSystemEventReceivers();
|
||||
systemReceiversRegistered = true;
|
||||
}
|
||||
if (bridge != null) {
|
||||
bridge.onStart();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
if (bridge != null) {
|
||||
bridge.onResume();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
super.onPause();
|
||||
if (bridge != null) {
|
||||
bridge.onPause();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStop() {
|
||||
super.onStop();
|
||||
if (systemReceiversRegistered) {
|
||||
unregisterSystemEventReceivers();
|
||||
systemReceiversRegistered = false;
|
||||
}
|
||||
if (bridge != null) {
|
||||
bridge.onStop();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLowMemory() {
|
||||
super.onLowMemory();
|
||||
if (bridge != null) {
|
||||
bridge.onLowMemory();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
unregisterSystemEventReceivers();
|
||||
if (bridge != null) {
|
||||
bridge.shutdown();
|
||||
}
|
||||
if (webView != null) {
|
||||
webView.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
if (webView != null && webView.canGoBack()) {
|
||||
webView.goBack();
|
||||
} else {
|
||||
super.onBackPressed();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.wails.app;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.ServiceInfo;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
|
||||
/**
|
||||
* A minimal started foreground service. It does no work of its own — its purpose
|
||||
* is to keep the app's process alive (with the required ongoing notification) so
|
||||
* the developer's Go goroutines keep running while the app is backgrounded,
|
||||
* which Android would otherwise be free to kill. Start it from
|
||||
* {@link WailsBridge#startForegroundService(String)} and stop it with
|
||||
* {@link WailsBridge#stopForegroundService()}.
|
||||
*/
|
||||
public class WailsForegroundService extends android.app.Service {
|
||||
public static final String ACTION_START = "com.wails.app.FGS_START";
|
||||
private static final String CHANNEL_ID = "wails_foreground";
|
||||
private static final int NOTIFICATION_ID = 0x57A1; // "WAI"
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
String title = "Wails";
|
||||
String text = "Running in the background";
|
||||
if (intent != null) {
|
||||
if (intent.getStringExtra("title") != null) title = intent.getStringExtra("title");
|
||||
if (intent.getStringExtra("text") != null) text = intent.getStringExtra("text");
|
||||
}
|
||||
|
||||
NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel ch = new NotificationChannel(
|
||||
CHANNEL_ID, "Background work", NotificationManager.IMPORTANCE_LOW);
|
||||
nm.createNotificationChannel(ch);
|
||||
}
|
||||
|
||||
PendingIntent contentIntent = null;
|
||||
Intent launch = getPackageManager().getLaunchIntentForPackage(getPackageName());
|
||||
if (launch != null) {
|
||||
int piFlags = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
|
||||
? PendingIntent.FLAG_IMMUTABLE : 0;
|
||||
contentIntent = PendingIntent.getActivity(this, 0, launch, piFlags);
|
||||
}
|
||||
|
||||
Notification n = new NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_popup_sync)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setOngoing(true)
|
||||
.setContentIntent(contentIntent)
|
||||
.build();
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIFICATION_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, n);
|
||||
}
|
||||
// Restart if the OS kills us while still wanted.
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.wails.app;
|
||||
|
||||
import android.util.Log;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import android.webkit.JavascriptInterface;
|
||||
import android.webkit.WebView;
|
||||
import com.wails.app.BuildConfig;
|
||||
|
||||
/**
|
||||
* WailsJSBridge provides the JavaScript interface that allows the web frontend
|
||||
* to communicate with the Go backend. This is exposed to JavaScript as the
|
||||
* `window.wails` object.
|
||||
*
|
||||
* Similar to iOS's WKScriptMessageHandler but using Android's addJavascriptInterface.
|
||||
*/
|
||||
public class WailsJSBridge {
|
||||
private static final String TAG = "WailsJSBridge";
|
||||
private static final boolean DEBUG = BuildConfig.DEBUG;
|
||||
// Pooled threads avoid unbounded thread creation under high call volume.
|
||||
private static final ExecutorService executor = Executors.newCachedThreadPool();
|
||||
|
||||
private final WailsBridge bridge;
|
||||
private final WebView webView;
|
||||
|
||||
public WailsJSBridge(WailsBridge bridge, WebView webView) {
|
||||
this.bridge = bridge;
|
||||
this.webView = webView;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to Go and return the response synchronously.
|
||||
* Called from JavaScript: wails.invoke(message)
|
||||
*
|
||||
* @param message The message to send (JSON string)
|
||||
* @return The response from Go (JSON string)
|
||||
*/
|
||||
@JavascriptInterface
|
||||
public String invoke(String message) {
|
||||
if (DEBUG) Log.d(TAG, "Invoke called: " + message);
|
||||
return bridge.handleMessage(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to Go asynchronously.
|
||||
* The response will be sent back via a callback.
|
||||
* Called from JavaScript: wails.invokeAsync(callbackId, message)
|
||||
*
|
||||
* @param callbackId The callback ID to use for the response
|
||||
* @param message The message to send (JSON string)
|
||||
*/
|
||||
@JavascriptInterface
|
||||
public void invokeAsync(final String callbackId, final String payload) {
|
||||
if (DEBUG) Log.d(TAG, "InvokeAsync called: " + payload);
|
||||
|
||||
// Handle off the JS thread so we don't block the WebView.
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
String response = bridge.handleRuntimeCall(payload);
|
||||
sendCallback(callbackId, response, null);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error in async invoke", e);
|
||||
sendCallback(callbackId, null, e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a message from JavaScript to Android's logcat
|
||||
* Called from JavaScript: wails.log(level, message)
|
||||
*
|
||||
* @param level The log level (debug, info, warn, error)
|
||||
* @param message The message to log
|
||||
*/
|
||||
@JavascriptInterface
|
||||
public void log(String level, String message) {
|
||||
switch (level.toLowerCase()) {
|
||||
case "debug":
|
||||
Log.d(TAG + "/JS", message);
|
||||
break;
|
||||
case "info":
|
||||
Log.i(TAG + "/JS", message);
|
||||
break;
|
||||
case "warn":
|
||||
Log.w(TAG + "/JS", message);
|
||||
break;
|
||||
case "error":
|
||||
Log.e(TAG + "/JS", message);
|
||||
break;
|
||||
default:
|
||||
Log.v(TAG + "/JS", message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the platform name
|
||||
* Called from JavaScript: wails.platform()
|
||||
*
|
||||
* @return "android"
|
||||
*/
|
||||
@JavascriptInterface
|
||||
public String platform() {
|
||||
return "android";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're running in debug mode
|
||||
* Called from JavaScript: wails.isDebug()
|
||||
*
|
||||
* @return true if debug build, false otherwise
|
||||
*/
|
||||
@JavascriptInterface
|
||||
public boolean isDebug() {
|
||||
return BuildConfig.DEBUG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a callback response to JavaScript
|
||||
*/
|
||||
private void sendCallback(String callbackId, String result, String error) {
|
||||
final String js;
|
||||
if (error != null) {
|
||||
js = String.format(
|
||||
"window._wailsAndroidCallback && window._wailsAndroidCallback('%s', null, '%s');",
|
||||
escapeJsString(callbackId),
|
||||
escapeJsString(error)
|
||||
);
|
||||
} else {
|
||||
js = String.format(
|
||||
"window._wailsAndroidCallback && window._wailsAndroidCallback('%s', '%s', null);",
|
||||
escapeJsString(callbackId),
|
||||
escapeJsString(result != null ? result : "")
|
||||
);
|
||||
}
|
||||
|
||||
webView.post(() -> webView.evaluateJavascript(js, null));
|
||||
}
|
||||
|
||||
private String escapeJsString(String str) {
|
||||
if (str == null) return "";
|
||||
return str.replace("\\", "\\\\")
|
||||
.replace("'", "\\'")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
// JS line terminators (U+2028/U+2029) must be escaped too; built via
|
||||
// (char) casts so the Java lexer does not reinterpret them as newlines.
|
||||
.replace(String.valueOf((char) 0x2028), "\\u2028")
|
||||
.replace(String.valueOf((char) 0x2029), "\\u2029");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.wails.app;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.util.Log;
|
||||
import android.webkit.WebResourceResponse;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.webkit.WebViewAssetLoader;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* WailsPathHandler implements WebViewAssetLoader.PathHandler to serve assets
|
||||
* from the Go asset server. This allows the WebView to load assets without
|
||||
* using a network server, similar to iOS's WKURLSchemeHandler.
|
||||
*/
|
||||
public class WailsPathHandler implements WebViewAssetLoader.PathHandler {
|
||||
private static final String TAG = "WailsPathHandler";
|
||||
private static final boolean DEBUG = BuildConfig.DEBUG;
|
||||
|
||||
private final WailsBridge bridge;
|
||||
|
||||
public WailsPathHandler(WailsBridge bridge) {
|
||||
this.bridge = bridge;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public WebResourceResponse handle(@NonNull String path) {
|
||||
if (DEBUG) Log.d(TAG, "Handling path: " + path);
|
||||
|
||||
// Normalize path
|
||||
if (path.isEmpty() || path.equals("/")) {
|
||||
path = "/index.html";
|
||||
}
|
||||
|
||||
// Get asset from Go
|
||||
byte[] data = bridge.serveAsset(path, "GET", "{}");
|
||||
|
||||
if (data == null || data.length == 0) {
|
||||
Log.w(TAG, "Asset not found: " + path);
|
||||
return null; // Return null to let WebView handle 404
|
||||
}
|
||||
|
||||
// Determine MIME type
|
||||
String mimeType = bridge.getAssetMimeType(path);
|
||||
if (DEBUG) Log.d(TAG, "Serving " + path + " with type " + mimeType + " (" + data.length + " bytes)");
|
||||
|
||||
// Create response
|
||||
InputStream inputStream = new ByteArrayInputStream(data);
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
headers.put("Access-Control-Allow-Origin", "*");
|
||||
headers.put("Cache-Control", "no-cache");
|
||||
|
||||
return new WebResourceResponse(
|
||||
mimeType,
|
||||
"UTF-8",
|
||||
200,
|
||||
"OK",
|
||||
headers,
|
||||
inputStream
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/main_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<WebView
|
||||
android:id="@+id/webview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
</FrameLayout>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="wails_blue">#3574D4</color>
|
||||
<color name="wails_blue_dark">#2C5FB8</color>
|
||||
<color name="wails_background">#1B2636</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
<color name="black">#FF000000</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Wails App</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.WailsApp" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">@color/wails_blue</item>
|
||||
<item name="colorPrimaryVariant">@color/wails_blue_dark</item>
|
||||
<item name="colorOnPrimary">@android:color/white</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor">@color/wails_background</item>
|
||||
<item name="android:navigationBarColor">@color/wails_background</item>
|
||||
<!-- Window background -->
|
||||
<item name="android:windowBackground">@color/wails_background</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<!-- Camera captures are written here and shared with the camera app via the
|
||||
FileProvider. -->
|
||||
<cache-path name="captures" path="captures/" />
|
||||
</paths>
|
||||