wails/v3/pkg/application/application_android.go
Lea Anthony 4dce80d887
fix(v3): revert goccy/go-json to stdlib encoding/json to fix Windows panic (#4859)
* fix(v3): warm up dialog types in go-json cache to prevent Windows panic

Add FileFilter, OpenFileDialogOptions, SaveFileDialogOptions, and
MessageDialogOptions to the init() warmup to prevent index out of bounds
panic on Windows when these types are first unmarshaled.

Fixes goccy/go-json#474 for Wails internal dialog types.

* fix(v3): revert goccy/go-json to stdlib encoding/json to fix Windows panic

goccy/go-json has a type address calculation bug on Windows that causes
index out of bounds panic when decoding user-defined types for the first time.

This reverts all runtime usages of goccy/go-json back to stdlib encoding/json.
Test and benchmark files are left unchanged.

Partially reverts PR #4843.
2026-01-05 08:26:35 +11:00

687 lines
20 KiB
Go

//go:build android && cgo
package application
/*
#include <jni.h>
#include <stdlib.h>
#include <string.h>
// Global JavaVM reference for thread attachment
static JavaVM* g_jvm = NULL;
// Global reference to bridge object (must be a global ref, not local)
static jobject g_bridge = NULL;
// Cached method ID for executeJavaScript
static jmethodID g_executeJsMethod = NULL;
// Helper function to convert Java String to C string
static const char* jstringToC(JNIEnv *env, jstring jstr) {
if (jstr == NULL) return NULL;
return (*env)->GetStringUTFChars(env, jstr, NULL);
}
// Helper function to release Java String
static void releaseJString(JNIEnv *env, jstring jstr, const char* cstr) {
if (jstr != NULL && cstr != NULL) {
(*env)->ReleaseStringUTFChars(env, jstr, cstr);
}
}
// Helper function to create Java byte array from C data
static jbyteArray createByteArray(JNIEnv *env, const void* data, int len) {
if (data == NULL || len <= 0) return NULL;
jbyteArray arr = (*env)->NewByteArray(env, len);
if (arr != NULL) {
(*env)->SetByteArrayRegion(env, arr, 0, len, (const jbyte*)data);
}
return arr;
}
// Helper function to create Java String from C string
static jstring createJString(JNIEnv *env, const char* str) {
if (str == NULL) return NULL;
return (*env)->NewStringUTF(env, str);
}
// Store JavaVM and create global reference to bridge
static void storeBridgeRef(JNIEnv *env, jobject bridge) {
// Get JavaVM
if ((*env)->GetJavaVM(env, &g_jvm) != 0) {
return;
}
// Create global reference to bridge object
g_bridge = (*env)->NewGlobalRef(env, bridge);
if (g_bridge == NULL) {
return;
}
// Cache the executeJavaScript method ID
jclass bridgeClass = (*env)->GetObjectClass(env, g_bridge);
if (bridgeClass != NULL) {
g_executeJsMethod = (*env)->GetMethodID(env, bridgeClass, "executeJavaScript", "(Ljava/lang/String;)V");
(*env)->DeleteLocalRef(env, bridgeClass);
}
}
// Android logging via __android_log_print
#include <android/log.h>
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, "WailsNative", __VA_ARGS__)
// Execute JavaScript via the bridge - can be called from any thread
static void executeJavaScriptOnBridge(const char* js) {
LOGD("executeJavaScriptOnBridge called, js length: %d", js ? (int)strlen(js) : -1);
if (g_jvm == NULL) {
LOGD("executeJavaScriptOnBridge: g_jvm is NULL");
return;
}
if (g_bridge == NULL) {
LOGD("executeJavaScriptOnBridge: g_bridge is NULL");
return;
}
if (g_executeJsMethod == NULL) {
LOGD("executeJavaScriptOnBridge: g_executeJsMethod is NULL");
return;
}
if (js == NULL) {
LOGD("executeJavaScriptOnBridge: js is NULL");
return;
}
JNIEnv *env = NULL;
int needsDetach = 0;
// Get JNIEnv for current thread
jint result = (*g_jvm)->GetEnv(g_jvm, (void**)&env, JNI_VERSION_1_6);
LOGD("executeJavaScriptOnBridge: GetEnv result = %d", result);
if (result == JNI_EDETACHED) {
// Attach current thread to JVM
LOGD("executeJavaScriptOnBridge: Attaching thread");
if ((*g_jvm)->AttachCurrentThread(g_jvm, &env, NULL) != 0) {
LOGD("executeJavaScriptOnBridge: AttachCurrentThread failed");
return;
}
needsDetach = 1;
} else if (result != JNI_OK) {
LOGD("executeJavaScriptOnBridge: GetEnv failed with %d", result);
return;
}
// Create Java string and call method
jstring jJs = (*env)->NewStringUTF(env, js);
LOGD("executeJavaScriptOnBridge: jJs created: %p", jJs);
if (jJs != NULL) {
LOGD("executeJavaScriptOnBridge: Calling Java method");
(*env)->CallVoidMethod(env, g_bridge, g_executeJsMethod, jJs);
LOGD("executeJavaScriptOnBridge: Java method called");
(*env)->DeleteLocalRef(env, jJs);
}
// Check for exceptions
if ((*env)->ExceptionCheck(env)) {
LOGD("executeJavaScriptOnBridge: Exception occurred!");
(*env)->ExceptionDescribe(env);
(*env)->ExceptionClear(env);
}
// Detach if we attached
if (needsDetach) {
LOGD("executeJavaScriptOnBridge: Detaching thread");
(*g_jvm)->DetachCurrentThread(g_jvm);
}
LOGD("executeJavaScriptOnBridge: Done");
}
*/
import "C"
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"time"
"unsafe"
"encoding/json"
"github.com/wailsapp/wails/v3/internal/runtime"
)
var (
// Global reference to the app for JNI callbacks
globalApp *App
globalAppLock sync.RWMutex
// JNI environment and class references
javaVM unsafe.Pointer
bridgeObject unsafe.Pointer
// Android main function registration
androidMainFunc func()
androidMainLock sync.Mutex
// App ready signal
appReady = make(chan struct{})
appReadyOnce sync.Once
)
func init() {
androidLogf("info", "🤖 [application_android.go] init() called")
}
// RegisterAndroidMain registers the main function to be called when the Android app starts.
// This should be called from init() in your main.go file for Android builds.
// Example:
//
// func init() {
// application.RegisterAndroidMain(main)
// }
func RegisterAndroidMain(mainFunc func()) {
androidMainLock.Lock()
defer androidMainLock.Unlock()
androidMainFunc = mainFunc
androidLogf("info", "🤖 [application_android.go] Android main function registered")
}
// signalAppReady signals that the app is ready to serve requests
func signalAppReady() {
appReadyOnce.Do(func() {
close(appReady)
androidLogf("info", "🤖 [application_android.go] App ready signal sent")
})
}
// waitForAppReady waits for the app to be ready, with a timeout
func waitForAppReady(timeout time.Duration) bool {
select {
case <-appReady:
return true
case <-time.After(timeout):
return false
}
}
func androidLogf(level string, format string, a ...interface{}) {
msg := fmt.Sprintf(format, a...)
// For now, just use println - we'll connect to Android's Log.* later
println(fmt.Sprintf("[Android/%s] %s", level, msg))
}
func (a *App) platformRun() {
androidLogf("info", "🤖 [application_android.go] platformRun() called")
// Store global reference for JNI callbacks
globalAppLock.Lock()
globalApp = a
globalAppLock.Unlock()
// Signal that the app is ready to serve requests
signalAppReady()
androidLogf("info", "🤖 [application_android.go] App ready, waiting for Android lifecycle...")
// Block forever - Android manages the app lifecycle via JNI callbacks
select {}
}
func (a *App) platformQuit() {
androidLogf("info", "🤖 [application_android.go] platformQuit() called")
// Android will handle app termination
}
func (a *App) isDarkMode() bool {
// TODO: Query Android for dark mode status
return false
}
func (a *App) isWindows() bool {
return false
}
// Platform-specific app implementation for Android
type androidApp struct {
parent *App
}
func newPlatformApp(app *App) *androidApp {
androidLogf("info", "🤖 [application_android.go] newPlatformApp() called")
return &androidApp{
parent: app,
}
}
func (a *androidApp) run() error {
androidLogf("info", "🤖 [application_android.go] androidApp.run() called")
// Emit application started event
a.parent.Event.Emit("ApplicationStarted")
a.parent.platformRun()
return nil
}
func (a *androidApp) destroy() {
androidLogf("info", "🤖 [application_android.go] androidApp.destroy() called")
}
func (a *androidApp) setIcon(_ []byte) {
// Android app icon is set through AndroidManifest.xml
}
func (a *androidApp) name() string {
return a.parent.options.Name
}
func (a *androidApp) GetFlags(options Options) map[string]any {
return nil
}
func (a *androidApp) getAccentColor() string {
return ""
}
func (a *androidApp) getCurrentWindowID() uint {
return 0
}
func (a *androidApp) hide() {
// Android manages app visibility
}
func (a *androidApp) isDarkMode() bool {
return a.parent.isDarkMode()
}
func (a *androidApp) on(_ uint) {
// Android event handling
}
func (a *androidApp) setApplicationMenu(_ *Menu) {
// Android doesn't have application menus in the same way
}
func (a *androidApp) show() {
// Android manages app visibility
}
func (a *androidApp) showAboutDialog(_ string, _ string, _ []byte) {
// TODO: Implement Android about dialog
}
func (a *androidApp) getPrimaryScreen() (*Screen, error) {
screens, err := getScreens()
if err != nil || len(screens) == 0 {
return nil, err
}
return screens[0], nil
}
func (a *androidApp) getScreens() ([]*Screen, error) {
return getScreens()
}
func (a *App) logPlatformInfo() {
// Log Android platform info
androidLogf("info", "Platform: Android")
}
func (a *App) platformEnvironment() map[string]any {
return map[string]any{
"platform": "android",
}
}
func fatalHandler(errFunc func(error)) {
// Android fatal handler
}
// JNI Export Functions - Called from Java
//export Java_com_wails_app_WailsBridge_nativeInit
func Java_com_wails_app_WailsBridge_nativeInit(env *C.JNIEnv, obj C.jobject, bridge C.jobject) {
androidLogf("info", "🤖 [JNI] nativeInit called")
// Store references for later use (legacy - keeping for compatibility)
javaVM = unsafe.Pointer(env)
bridgeObject = unsafe.Pointer(bridge)
// Store JavaVM and bridge global reference for JNI callbacks
C.storeBridgeRef(env, bridge)
androidLogf("info", "🤖 [JNI] Bridge reference stored for JNI callbacks")
// Start the registered main function in a goroutine
androidMainLock.Lock()
mainFunc := androidMainFunc
androidMainLock.Unlock()
if mainFunc != nil {
androidLogf("info", "🤖 [JNI] Starting registered main function in goroutine")
go mainFunc()
} else {
androidLogf("warn", "🤖 [JNI] No main function registered! Call application.RegisterAndroidMain(main) in init()")
}
androidLogf("info", "🤖 [JNI] nativeInit complete")
}
//export Java_com_wails_app_WailsBridge_nativeShutdown
func Java_com_wails_app_WailsBridge_nativeShutdown(env *C.JNIEnv, obj C.jobject) {
androidLogf("info", "🤖 [JNI] nativeShutdown called")
globalAppLock.Lock()
if globalApp != nil {
globalApp.Quit()
}
globalAppLock.Unlock()
}
//export Java_com_wails_app_WailsBridge_nativeOnResume
func Java_com_wails_app_WailsBridge_nativeOnResume(env *C.JNIEnv, obj C.jobject) {
androidLogf("info", "🤖 [JNI] nativeOnResume called")
globalAppLock.RLock()
app := globalApp
globalAppLock.RUnlock()
if app != nil {
app.Event.Emit("ApplicationResumed")
}
}
//export Java_com_wails_app_WailsBridge_nativeOnPause
func Java_com_wails_app_WailsBridge_nativeOnPause(env *C.JNIEnv, obj C.jobject) {
androidLogf("info", "🤖 [JNI] nativeOnPause called")
globalAppLock.RLock()
app := globalApp
globalAppLock.RUnlock()
if app != nil {
app.Event.Emit("ApplicationPaused")
}
}
//export Java_com_wails_app_WailsBridge_nativeOnPageFinished
func Java_com_wails_app_WailsBridge_nativeOnPageFinished(env *C.JNIEnv, obj C.jobject, jurl C.jstring) {
cUrl := C.jstringToC(env, jurl)
defer C.releaseJString(env, jurl, cUrl)
url := C.GoString(cUrl)
androidLogf("info", "🤖 [JNI] nativeOnPageFinished called: %s", url)
globalAppLock.RLock()
app := globalApp
globalAppLock.RUnlock()
if app == nil {
androidLogf("error", "🤖 [JNI] nativeOnPageFinished: app is nil")
return
}
// Inject the runtime into the first window (with proper locking)
app.windowsLock.RLock()
windowCount := len(app.windows)
androidLogf("info", "🤖 [JNI] nativeOnPageFinished: window count = %d", windowCount)
for id, win := range app.windows {
androidLogf("info", "🤖 [JNI] Found window ID: %d", id)
if win != nil {
androidLogf("info", "🤖 [JNI] Injecting runtime.Core() into window %d", id)
// Get the runtime core JavaScript
runtimeJS := runtime.Core()
androidLogf("info", "🤖 [JNI] Runtime JS length: %d bytes", len(runtimeJS))
app.windowsLock.RUnlock()
// IMPORTANT: We must bypass win.ExecJS because it queues if runtimeLoaded is false.
// On Android, we need to inject the runtime directly since the runtime hasn't been loaded yet.
// This is the bootstrap injection that enables the runtime to load.
androidLogf("info", "🤖 [JNI] Calling executeJavaScript directly (bypassing queue)")
executeJavaScript(runtimeJS)
// Emit event
app.Event.Emit("PageFinished", url)
return
}
}
app.windowsLock.RUnlock()
androidLogf("warn", "🤖 [JNI] nativeOnPageFinished: no windows found to inject runtime")
// Emit event even if no windows
app.Event.Emit("PageFinished", url)
}
//export Java_com_wails_app_WailsBridge_nativeServeAsset
func Java_com_wails_app_WailsBridge_nativeServeAsset(env *C.JNIEnv, obj C.jobject, jpath C.jstring, jmethod C.jstring, jheaders C.jstring) C.jbyteArray {
// Convert Java strings to Go strings
cPath := C.jstringToC(env, jpath)
cMethod := C.jstringToC(env, jmethod)
defer C.releaseJString(env, jpath, cPath)
defer C.releaseJString(env, jmethod, cMethod)
goPath := C.GoString(cPath)
goMethod := C.GoString(cMethod)
androidLogf("debug", "🤖 [JNI] nativeServeAsset: %s %s", goMethod, goPath)
// Wait for the app to be ready (timeout after 10 seconds)
if !waitForAppReady(10 * time.Second) {
androidLogf("error", "🤖 [JNI] Timeout waiting for app to be ready")
return C.createByteArray(env, nil, 0)
}
globalAppLock.RLock()
app := globalApp
globalAppLock.RUnlock()
if app == nil || app.assets == nil {
androidLogf("error", "🤖 [JNI] App or assets not initialized after ready signal")
return C.createByteArray(env, nil, 0)
}
// Serve the asset through the asset server
data, err := serveAssetForAndroid(app, goPath)
if err != nil {
androidLogf("error", "🤖 [JNI] Error serving asset %s: %v", goPath, err)
return C.createByteArray(env, nil, 0)
}
androidLogf("debug", "🤖 [JNI] Serving asset %s (%d bytes)", goPath, len(data))
// Create Java byte array from the data
// Handle empty data case to avoid index out of range panic
if len(data) == 0 {
return C.createByteArray(env, nil, 0)
}
return C.createByteArray(env, unsafe.Pointer(&data[0]), C.int(len(data)))
}
//export Java_com_wails_app_WailsBridge_nativeHandleMessage
func Java_com_wails_app_WailsBridge_nativeHandleMessage(env *C.JNIEnv, obj C.jobject, jmessage C.jstring) C.jstring {
// Convert Java string to Go string
cMessage := C.jstringToC(env, jmessage)
defer C.releaseJString(env, jmessage, cMessage)
goMessage := C.GoString(cMessage)
androidLogf("debug", "🤖 [JNI] nativeHandleMessage: %s", goMessage)
globalAppLock.RLock()
app := globalApp
globalAppLock.RUnlock()
if app == nil {
errorResponse := `{"error":"App not initialized"}`
return C.createJString(env, C.CString(errorResponse))
}
// Parse and handle the message
response := handleMessageForAndroid(app, goMessage)
return C.createJString(env, C.CString(response))
}
//export Java_com_wails_app_WailsBridge_nativeGetAssetMimeType
func Java_com_wails_app_WailsBridge_nativeGetAssetMimeType(env *C.JNIEnv, obj C.jobject, jpath C.jstring) C.jstring {
// Convert Java string to Go string
cPath := C.jstringToC(env, jpath)
defer C.releaseJString(env, jpath, cPath)
goPath := C.GoString(cPath)
mimeType := getMimeTypeForPath(goPath)
return C.createJString(env, C.CString(mimeType))
}
// Helper functions
func serveAssetForAndroid(app *App, path string) ([]byte, error) {
// Check if this is a runtime call (includes query string)
isRuntimeCall := strings.HasPrefix(path, "/wails/runtime")
// Normalize path for regular assets (not runtime calls)
if !isRuntimeCall {
if path == "" || path == "/" {
path = "/index.html"
}
}
// Ensure path starts with /
if len(path) > 0 && path[0] != '/' {
path = "/" + path
}
// Check if asset server is available
if app.assets == nil {
return nil, fmt.Errorf("asset server not initialized")
}
// Create a fake HTTP request
fullURL := "https://wails.localhost" + path
androidLogf("debug", "🤖 [serveAssetForAndroid] Creating request for: %s", fullURL)
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// For runtime calls (/wails/runtime), we need to add the window ID header
// This is required by the MessageProcessor to route the call correctly
if isRuntimeCall {
// Get the first window (on Android, there's typically only one)
windows := app.Window.GetAll()
androidLogf("debug", "🤖 [serveAssetForAndroid] Runtime call, found %d windows", len(windows))
if len(windows) > 0 {
// Use the first window's ID
windowID := windows[0].ID()
req.Header.Set("x-wails-window-id", fmt.Sprintf("%d", windowID))
androidLogf("debug", "🤖 [serveAssetForAndroid] Added window ID header: %d", windowID)
} else {
androidLogf("warn", "🤖 [serveAssetForAndroid] No windows available for runtime call")
}
}
// Use httptest.ResponseRecorder to capture the response
recorder := httptest.NewRecorder()
// Serve the request through the asset server
app.assets.ServeHTTP(recorder, req)
// Check response status
result := recorder.Result()
defer result.Body.Close()
// Read the response body
body, err := io.ReadAll(result.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
androidLogf("debug", "🤖 [serveAssetForAndroid] Response status: %d, body length: %d", result.StatusCode, len(body))
// For runtime calls, we need to return the body even for error responses
// so the JavaScript can see the error message
if isRuntimeCall {
if result.StatusCode != http.StatusOK {
androidLogf("warn", "🤖 [serveAssetForAndroid] Runtime call returned status %d: %s", result.StatusCode, string(body))
}
// Return the body regardless of status - the JS will handle errors
return body, nil
}
// For regular assets, check status codes
if result.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("asset not found: %s", path)
}
if result.StatusCode != http.StatusOK {
return nil, fmt.Errorf("asset server error: status %d for %s", result.StatusCode, path)
}
return body, nil
}
func handleMessageForAndroid(app *App, message string) string {
// Parse the message
var msg map[string]interface{}
if err := json.Unmarshal([]byte(message), &msg); err != nil {
return fmt.Sprintf(`{"error":"%s"}`, err.Error())
}
// TODO: Route to appropriate handler based on message type
// For now, return success
return `{"success":true}`
}
func getMimeTypeForPath(path string) string {
// Simple MIME type detection based on extension
switch {
case endsWith(path, ".html"), endsWith(path, ".htm"):
return "text/html"
case endsWith(path, ".js"), endsWith(path, ".mjs"):
return "application/javascript"
case endsWith(path, ".css"):
return "text/css"
case endsWith(path, ".json"):
return "application/json"
case endsWith(path, ".png"):
return "image/png"
case endsWith(path, ".jpg"), endsWith(path, ".jpeg"):
return "image/jpeg"
case endsWith(path, ".gif"):
return "image/gif"
case endsWith(path, ".svg"):
return "image/svg+xml"
case endsWith(path, ".ico"):
return "image/x-icon"
case endsWith(path, ".woff"):
return "font/woff"
case endsWith(path, ".woff2"):
return "font/woff2"
case endsWith(path, ".ttf"):
return "font/ttf"
default:
return "application/octet-stream"
}
}
func endsWith(s, suffix string) bool {
return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
}
// executeJavaScript executes JavaScript code in the Android WebView via JNI callback
func executeJavaScript(js string) {
androidLogf("info", "🤖 executeJavaScript called, length: %d", len(js))
if js == "" {
androidLogf("warn", "🤖 executeJavaScript: empty JS string")
return
}
// Convert Go string to C string and call the JNI bridge
androidLogf("info", "🤖 executeJavaScript: calling C.executeJavaScriptOnBridge")
cJs := C.CString(js)
defer C.free(unsafe.Pointer(cJs))
C.executeJavaScriptOnBridge(cJs)
androidLogf("info", "🤖 executeJavaScript: done")
}