From 8ce3374bd02a3aefed2210319fc614e2000992b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 14:04:21 +0000 Subject: [PATCH 01/19] feat(android): real JNI binding; route Android backend by abi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Android bridge declared `extern fn jni_*` symbols that were defined nowhere, so it could never link; and main.zig dispatched on os.tag, which is .linux for Android — so it selected the GTK backend and webview_android.zig was dead code. - jni.zig: model the JNINativeInterface/JNIInvokeInterface tables in pure Zig (ordinal-indexed, ...A-variant calls to avoid C varargs across the boundary, cached JavaVM for thread attach). Host-compiled wrapper type-check test. - webview_android.zig: rewritten onto jni.zig; promote local refs to globals (the old code stored refs that die after nativeInit); drop the duplicate addJavascriptInterface registration; force-link the component hosts. - main.zig: select the Android backend by `abi == .android` before the os switch. - build.zig: link NDK log/android and skip WebKitGTK for the android abi. https://claude.ai/code/session_01GsJX13UjwiBk9hkddqvYMh --- .../java/io/gossamer/GossamerActivity.java | 96 ---- .../main/java/io/gossamer/GossamerBridge.java | 39 -- src/interface/ffi/build.zig | 57 ++- src/interface/ffi/src/jni.zig | 359 +++++++++++++++ src/interface/ffi/src/main.zig | 13 +- src/interface/ffi/src/webview_android.zig | 416 +++++++----------- 6 files changed, 585 insertions(+), 395 deletions(-) delete mode 100644 android/src/main/java/io/gossamer/GossamerActivity.java delete mode 100644 android/src/main/java/io/gossamer/GossamerBridge.java create mode 100644 src/interface/ffi/src/jni.zig diff --git a/android/src/main/java/io/gossamer/GossamerActivity.java b/android/src/main/java/io/gossamer/GossamerActivity.java deleted file mode 100644 index 882b0b3..0000000 --- a/android/src/main/java/io/gossamer/GossamerActivity.java +++ /dev/null @@ -1,96 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) - -package io.gossamer; - -import android.app.Activity; -import android.os.Bundle; -import android.webkit.WebSettings; -import android.webkit.WebView; -import android.webkit.WebViewClient; - -/** - * GossamerActivity — Hosts a WebView and bridges to the native Gossamer library. - * - * This Activity creates a full-screen WebView and passes global JNI references - * to the native layer via nativeInit(). The native library (libgossamer.so) is - * loaded via System.loadLibrary in a static block. - * - * Subclass this and override getInitialUrl() or getInitialHtml() to provide - * your application content. - */ -public class GossamerActivity extends Activity { - - private WebView webView; - - static { - System.loadLibrary("gossamer"); - } - - // Native methods implemented in webview_android.zig - private static native void nativeInit(Activity activity, WebView webview); - private static native void nativeDestroy(); - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - webView = new WebView(this); - setContentView(webView); - - // Configure WebView for Gossamer IPC - WebSettings settings = webView.getSettings(); - settings.setJavaScriptEnabled(true); - settings.setDomStorageEnabled(true); - settings.setAllowFileAccess(false); // Security: no file:// access - settings.setAllowContentAccess(false); - - // Prevent WebView from opening external browser - webView.setWebViewClient(new WebViewClient()); - - // Register the GossamerBridge JavaScript interface - GossamerBridge bridge = new GossamerBridge(); - webView.addJavascriptInterface(bridge, "GossamerBridge"); - - // Pass global references to the native layer - nativeInit(this, webView); - - // Load initial content - String html = getInitialHtml(); - if (html != null) { - webView.loadData(html, "text/html", "UTF-8"); - } else { - String url = getInitialUrl(); - if (url != null) { - webView.loadUrl(url); - } - } - } - - @Override - protected void onDestroy() { - nativeDestroy(); - if (webView != null) { - webView.removeJavascriptInterface("GossamerBridge"); - webView.destroy(); - webView = null; - } - super.onDestroy(); - } - - /** - * Override to provide an initial URL to load. - * Return null to use getInitialHtml() instead. - */ - protected String getInitialUrl() { - return null; - } - - /** - * Override to provide initial HTML content. - * Return null if using getInitialUrl(). - */ - protected String getInitialHtml() { - return "

Gossamer

Override getInitialHtml() or getInitialUrl().

"; - } -} diff --git a/android/src/main/java/io/gossamer/GossamerBridge.java b/android/src/main/java/io/gossamer/GossamerBridge.java deleted file mode 100644 index 304cd3b..0000000 --- a/android/src/main/java/io/gossamer/GossamerBridge.java +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) - -package io.gossamer; - -import android.webkit.JavascriptInterface; - -/** - * GossamerBridge — JavaScript interface exposed to the WebView. - * - * When JavaScript calls `window.GossamerBridge.postMessage(msg)`, - * the message is forwarded to the native Gossamer library via JNI. - * The native side parses the JSON message, dispatches to the bound - * callback, and sends the response back via evaluateJavascript(). - * - * This class is registered via WebView.addJavascriptInterface() in - * GossamerActivity.onCreate(). - */ -public class GossamerBridge { - - // Native method implemented in webview_android.zig - private static native void nativePostMessage(String message); - - /** - * Called from JavaScript via the Gossamer IPC bridge. - * - * The message is a JSON string with the following structure: - * {"id":"abc123","name":"command_name","payload":"{...}"} - * - * @param message JSON-encoded IPC message - */ - @JavascriptInterface - public void postMessage(String message) { - if (message == null || message.isEmpty()) { - return; - } - nativePostMessage(message); - } -} diff --git a/src/interface/ffi/build.zig b/src/interface/ffi/build.zig index 55e2e07..d03cee9 100644 --- a/src/interface/ffi/build.zig +++ b/src/interface/ffi/build.zig @@ -29,7 +29,18 @@ const std = @import("std"); /// Link platform-specific system libraries to a module. -fn linkPlatformLibs(module: *std.Build.Module, os: std.Target.Os.Tag) void { +/// +/// `abi` is consulted first because an Android target reports `os == .linux` +/// (it runs a Linux kernel) yet must NOT link GTK/WebKitGTK — its WebView and +/// component hosts are reached entirely over JNI. The only NDK libraries the +/// shell needs are liblog (diagnostics) and libandroid; libc is linked by the +/// module itself. +fn linkPlatformLibs(module: *std.Build.Module, os: std.Target.Os.Tag, abi: std.Target.Abi) void { + if (abi == .android) { + module.linkSystemLibrary("log", .{}); + module.linkSystemLibrary("android", .{}); + return; + } switch (os) { .linux, .freebsd, .openbsd, .netbsd => { // GTK 3 + WebKitGTK 4.1 (same across Linux and BSD) @@ -64,6 +75,7 @@ pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); const os = target.query.os_tag orelse builtin.os.tag; + const abi = target.result.abi; // Create the root module (shared between shared/static/test) const root_module = b.createModule(.{ @@ -73,7 +85,7 @@ pub fn build(b: *std.Build) void { .link_libc = true, }); - linkPlatformLibs(root_module, os); + linkPlatformLibs(root_module, os, abi); // --- Shared library (.so / .dylib / .dll) --- const shared_lib = b.addLibrary(.{ @@ -93,7 +105,7 @@ pub fn build(b: *std.Build) void { .link_libc = true, }); - linkPlatformLibs(static_module, os); + linkPlatformLibs(static_module, os, abi); const static_lib = b.addLibrary(.{ .name = "gossamer", @@ -111,7 +123,7 @@ pub fn build(b: *std.Build) void { .link_libc = true, }); - linkPlatformLibs(test_module, os); + linkPlatformLibs(test_module, os, abi); const unit_tests = b.addTest(.{ .root_module = test_module, @@ -136,7 +148,7 @@ pub fn build(b: *std.Build) void { .link_libc = true, }); - linkPlatformLibs(gossamer_module, os); + linkPlatformLibs(gossamer_module, os, abi); const display_test_module = b.createModule(.{ .root_source_file = b.path("test/display_test.zig"), @@ -148,7 +160,7 @@ pub fn build(b: *std.Build) void { }, }); - linkPlatformLibs(display_test_module, os); + linkPlatformLibs(display_test_module, os, abi); const display_tests = b.addTest(.{ .root_module = display_test_module, @@ -157,6 +169,39 @@ pub fn build(b: *std.Build) void { const run_display_tests = b.addRunArtifact(display_tests); const display_test_step = b.step("test-display", "Run Gossamer display integration tests (requires X11/Wayland/Xvfb)"); display_test_step.dependOn(&run_display_tests.step); + + // --- Android component host logic tests (host-runnable; pure Zig, no NDK) --- + // The JNI binding and the Service/Receiver/Widget hosts are pure Zig, so + // their registry/dispatch/JSON/directive logic runs on the host. Wired into + // the default `test` step so CI exercises the component contract without a + // device. (The on-device JNI calls are validated separately on an emulator.) + const android_test_module = b.createModule(.{ + .root_source_file = b.path("test/android_components_test.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + // Deliberately NO linkPlatformLibs: these modules pull in neither GTK nor + // the NDK; keeping them library-free is what makes the tests host-runnable. + + const android_tests = b.addTest(.{ + .root_module = android_test_module, + }); + + const run_android_tests = b.addRunArtifact(android_tests); + const android_test_step = b.step("test-android", "Run Android component host logic tests (host-runnable, no NDK)"); + android_test_step.dependOn(&run_android_tests.step); + test_step.dependOn(&run_android_tests.step); + + // --- Android cross-compilation (requires the Android NDK) --- + // Produces libgossamer.so for each ABI neurophone (and other downstreams) + // ship. This is a thin wrapper over the standard target options; the + // per-ABI loop and jniLibs packaging live in the Justfile (`just + // android-build`). Selected purely so `zig build -Dtarget=-linux-android` + // routes through the JNI WebView backend and the component hosts. + // zig build -Dtarget=aarch64-linux-android # arm64-v8a + // zig build -Dtarget=x86_64-linux-android # x86_64 (emulator) + // zig build -Dtarget=arm-linux-androideabi # armeabi-v7a } const builtin = @import("builtin"); diff --git a/src/interface/ffi/src/jni.zig b/src/interface/ffi/src/jni.zig new file mode 100644 index 0000000..8c59528 --- /dev/null +++ b/src/interface/ffi/src/jni.zig @@ -0,0 +1,359 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Gossamer — Real JNI binding (pure Zig, no C shim) +// +// This module models the Java Native Interface function tables directly in +// Zig and provides typed wrappers. It exists because the previous Android +// bridge declared `extern fn jni_FindClass(...)` and friends — symbols that +// are defined NOWHERE. Real JNI is not a set of flat C symbols: every call is +// an indirect call through the per-thread `JNIEnv` function table +// (`(*env)->FindClass(env, ...)` in C). Without that indirection the Android +// shell cannot link, let alone run. +// +// Design choices that keep this binding correct without a local Android +// toolchain to compile against: +// +// 1. The function table is addressed BY INDEX through the canonical JNI +// ordinal table (stable across every Android API level since 1.6). We do +// not hand-transcribe ~232 struct fields — only the ordinals we use — +// so an off-by-one in an unused slot cannot silently corrupt a used one. +// +// 2. Method invocation uses the `...A` variants (`CallVoidMethodA`, +// `NewObjectA`, `CallStaticVoidMethodA`) which take a `jvalue[]` array +// rather than C varargs. This sidesteps the platform varargs ABI (a real +// hazard when passing a 64-bit `jlong` through `...` on arm64) and is the +// JNI-recommended path for generated/bridged callers. +// +// 3. Pointer-width and union layout follow the Android NDK `jni.h` exactly. +// +// This file is pure Zig and compiles on any target (it pulls in no Android +// headers); it is only LINKED into a working image when building for an +// `*-linux-android` target. That property lets its `test` blocks run on the +// host CI runner. + +const std = @import("std"); + +//============================================================================== +// Opaque JNI reference types +//============================================================================== + +/// `jobject` — opaque reference to any Java object (local or global). +pub const jobject = ?*anyopaque; +/// `jclass` — reference to a `java.lang.Class`. +pub const jclass = jobject; +/// `jstring` — reference to a `java.lang.String`. +pub const jstring = jobject; +/// `jthrowable` — reference to a `java.lang.Throwable`. +pub const jthrowable = jobject; +/// `jmethodID` — opaque method identifier (NOT a GC reference; stable). +pub const jmethodID = ?*anyopaque; +/// `jfieldID` — opaque field identifier. +pub const jfieldID = ?*anyopaque; + +/// JNI primitive scalar types (NDK `jni.h`). +pub const jint = i32; +pub const jlong = i64; +pub const jboolean = u8; +pub const jbyte = i8; +pub const jchar = u16; +pub const jshort = i16; +pub const jfloat = f32; +pub const jdouble = f64; +pub const jsize = jint; + +pub const JNI_TRUE: jboolean = 1; +pub const JNI_FALSE: jboolean = 0; + +/// JNI status / version constants used by the invocation API. +pub const JNI_OK: jint = 0; +pub const JNI_EDETACHED: jint = -2; +pub const JNI_EVERSION: jint = -3; +pub const JNI_VERSION_1_6: jint = 0x00010006; + +/// `jvalue` — the 8-byte C union passed to the `...A` call variants. +/// Modelled as an `extern union` so its size/alignment match the NDK. +pub const jvalue = extern union { + z: jboolean, + b: jbyte, + c: jchar, + s: jshort, + i: jint, + j: jlong, + f: jfloat, + d: jdouble, + l: jobject, +}; + +/// Construct a `jvalue` carrying an object reference. +pub inline fn vObj(o: jobject) jvalue { + return .{ .l = o }; +} +/// Construct a `jvalue` carrying a 64-bit `long` (e.g. a native pointer). +pub inline fn vLong(x: jlong) jvalue { + return .{ .j = x }; +} +/// Construct a `jvalue` carrying an `int`. +pub inline fn vInt(x: jint) jvalue { + return .{ .i = x }; +} + +//============================================================================== +// Function-table addressing +// +// `JNIEnv` is `const struct JNINativeInterface*`. The struct it points at is a +// flat table of function pointers. We model it as a many-item pointer of +// `*anyopaque` slots and index it by ordinal, casting each used slot to its +// real signature at the call site. +//============================================================================== + +/// The function table itself: an array of opaque function pointers. +pub const FunctionTable = [*]const ?*const anyopaque; + +/// `JNIEnv` — pointer to the function table. One per attached thread. +pub const JNIEnv = *const FunctionTable; + +/// Canonical JNI ordinals (`JNINativeInterface` field order, NDK `jni.h`). +/// Only the entries Gossamer calls are listed; the order is fixed by the spec. +const Ord = struct { + const ExceptionClear: usize = 17; + const NewGlobalRef: usize = 21; + const DeleteGlobalRef: usize = 22; + const NewObjectA: usize = 30; + const GetObjectClass: usize = 31; + const GetMethodID: usize = 33; + const CallObjectMethodA: usize = 36; + const CallVoidMethodA: usize = 63; + const GetStaticMethodID: usize = 113; + const CallStaticVoidMethodA: usize = 143; + const NewStringUTF: usize = 167; + const GetStringUTFChars: usize = 169; + const ReleaseStringUTFChars: usize = 170; + const GetJavaVM: usize = 219; + const ExceptionCheck: usize = 228; +}; + +/// Fetch table slot `ord` from `env` and reinterpret it as function type `Fn`. +inline fn slot(env: JNIEnv, comptime ord: usize, comptime Fn: type) Fn { + return @ptrCast(env.*[ord].?); +} + +//============================================================================== +// Typed wrappers — the only surface the rest of the shell should use +//============================================================================== + +/// `(*env)->FindClass(env, name)` — resolve a class by JNI name +/// (e.g. "android/webkit/WebView"). Returns null + a pending exception on miss. +pub fn findClass(env: JNIEnv, name: [*:0]const u8) jclass { + const FindClass = *const fn (JNIEnv, [*:0]const u8) callconv(.c) jclass; + // FindClass is ordinal 6; resolve directly (kept inline to avoid an unused + // ordinal constant when only a subset of callers need it). + const f: FindClass = @ptrCast(env.*[6].?); + return f(env, name); +} + +/// `(*env)->GetObjectClass(env, obj)`. +pub fn getObjectClass(env: JNIEnv, obj: jobject) jclass { + const F = *const fn (JNIEnv, jobject) callconv(.c) jclass; + return slot(env, Ord.GetObjectClass, F)(env, obj); +} + +/// `(*env)->GetMethodID(env, cls, name, sig)` — instance method id. +pub fn getMethodID(env: JNIEnv, cls: jclass, name: [*:0]const u8, sig: [*:0]const u8) jmethodID { + const F = *const fn (JNIEnv, jclass, [*:0]const u8, [*:0]const u8) callconv(.c) jmethodID; + return slot(env, Ord.GetMethodID, F)(env, cls, name, sig); +} + +/// `(*env)->GetStaticMethodID(env, cls, name, sig)` — static method id. +pub fn getStaticMethodID(env: JNIEnv, cls: jclass, name: [*:0]const u8, sig: [*:0]const u8) jmethodID { + const F = *const fn (JNIEnv, jclass, [*:0]const u8, [*:0]const u8) callconv(.c) jmethodID; + return slot(env, Ord.GetStaticMethodID, F)(env, cls, name, sig); +} + +/// `(*env)->NewObjectA(env, cls, ctor, args)` — construct an object. +pub fn newObject(env: JNIEnv, cls: jclass, ctor: jmethodID, args: []const jvalue) jobject { + const F = *const fn (JNIEnv, jclass, jmethodID, [*]const jvalue) callconv(.c) jobject; + return slot(env, Ord.NewObjectA, F)(env, cls, ctor, args.ptr); +} + +/// `(*env)->CallVoidMethodA(env, obj, mid, args)` — invoke a `void` instance method. +pub fn callVoidMethod(env: JNIEnv, obj: jobject, mid: jmethodID, args: []const jvalue) void { + const F = *const fn (JNIEnv, jobject, jmethodID, [*]const jvalue) callconv(.c) void; + slot(env, Ord.CallVoidMethodA, F)(env, obj, mid, args.ptr); +} + +/// `(*env)->CallObjectMethodA(env, obj, mid, args)` — invoke an Object-returning method. +pub fn callObjectMethod(env: JNIEnv, obj: jobject, mid: jmethodID, args: []const jvalue) jobject { + const F = *const fn (JNIEnv, jobject, jmethodID, [*]const jvalue) callconv(.c) jobject; + return slot(env, Ord.CallObjectMethodA, F)(env, obj, mid, args.ptr); +} + +/// `(*env)->CallStaticVoidMethodA(env, cls, mid, args)`. +pub fn callStaticVoidMethod(env: JNIEnv, cls: jclass, mid: jmethodID, args: []const jvalue) void { + const F = *const fn (JNIEnv, jclass, jmethodID, [*]const jvalue) callconv(.c) void; + slot(env, Ord.CallStaticVoidMethodA, F)(env, cls, mid, args.ptr); +} + +/// `(*env)->NewStringUTF(env, bytes)` — make a Java String from modified-UTF-8. +pub fn newStringUTF(env: JNIEnv, bytes: [*:0]const u8) jstring { + const F = *const fn (JNIEnv, [*:0]const u8) callconv(.c) jstring; + return slot(env, Ord.NewStringUTF, F)(env, bytes); +} + +/// `(*env)->GetStringUTFChars(env, s, isCopy)` — borrow the UTF-8 bytes. +/// Caller MUST pair every successful call with `releaseStringUTFChars`. +pub fn getStringUTFChars(env: JNIEnv, s: jstring) ?[*:0]const u8 { + const F = *const fn (JNIEnv, jstring, ?*jboolean) callconv(.c) ?[*:0]const u8; + return slot(env, Ord.GetStringUTFChars, F)(env, s, null); +} + +/// `(*env)->ReleaseStringUTFChars(env, s, chars)`. +pub fn releaseStringUTFChars(env: JNIEnv, s: jstring, chars: [*:0]const u8) void { + const F = *const fn (JNIEnv, jstring, [*:0]const u8) callconv(.c) void; + slot(env, Ord.ReleaseStringUTFChars, F)(env, s, chars); +} + +/// `(*env)->NewGlobalRef(env, o)` — promote a local ref to a process-global ref. +pub fn newGlobalRef(env: JNIEnv, o: jobject) jobject { + const F = *const fn (JNIEnv, jobject) callconv(.c) jobject; + return slot(env, Ord.NewGlobalRef, F)(env, o); +} + +/// `(*env)->DeleteGlobalRef(env, o)`. +pub fn deleteGlobalRef(env: JNIEnv, o: jobject) void { + const F = *const fn (JNIEnv, jobject) callconv(.c) void; + slot(env, Ord.DeleteGlobalRef, F)(env, o); +} + +/// `(*env)->ExceptionCheck(env)` — true if a Java exception is pending. +pub fn exceptionCheck(env: JNIEnv) bool { + const F = *const fn (JNIEnv) callconv(.c) jboolean; + return slot(env, Ord.ExceptionCheck, F)(env) != JNI_FALSE; +} + +/// `(*env)->ExceptionClear(env)` — discard any pending Java exception. +pub fn exceptionClear(env: JNIEnv) void { + const F = *const fn (JNIEnv) callconv(.c) void; + slot(env, Ord.ExceptionClear, F)(env); +} + +/// Clear a pending exception if one is present; return whether one was found. +/// Bridged callbacks must not return to the JVM with an exception still set. +pub fn clearPendingException(env: JNIEnv) bool { + if (exceptionCheck(env)) { + exceptionClear(env); + return true; + } + return false; +} + +//============================================================================== +// Invocation API (JavaVM) — needed to attach the Service/Receiver/Widget +// threads, which the JVM may invoke on threads that have no JNIEnv yet. +//============================================================================== + +/// The invocation function table (`JNIInvokeInterface`). +pub const InvokeTable = [*]const ?*const anyopaque; +/// `JavaVM` — pointer to the invocation table. One per process. +pub const JavaVM = *const InvokeTable; + +const InvokeOrd = struct { + const AttachCurrentThread: usize = 4; + const DetachCurrentThread: usize = 5; + const GetEnv: usize = 6; +}; + +inline fn vmSlot(vm: JavaVM, comptime ord: usize, comptime Fn: type) Fn { + return @ptrCast(vm.*[ord].?); +} + +/// `(*env)->GetJavaVM(env, &vm)` — recover the process `JavaVM` from any env. +pub fn getJavaVM(env: JNIEnv) ?JavaVM { + const F = *const fn (JNIEnv, *?JavaVM) callconv(.c) jint; + var vm: ?JavaVM = null; + const rc = slot(env, Ord.GetJavaVM, F)(env, &vm); + if (rc != JNI_OK) return null; + return vm; +} + +/// `(*vm)->GetEnv(vm, &env, version)` — fetch this thread's env if attached. +pub fn getEnv(vm: JavaVM, version: jint) ?JNIEnv { + const F = *const fn (JavaVM, *?*anyopaque, jint) callconv(.c) jint; + var env: ?*anyopaque = null; + const rc = vmSlot(vm, InvokeOrd.GetEnv, F)(vm, &env, version); + if (rc != JNI_OK) return null; + const e = env orelse return null; + return @ptrCast(@alignCast(e)); +} + +/// `(*vm)->AttachCurrentThread(vm, &env, null)` — attach a native thread so it +/// can make JNI calls. Returns the freshly-bound env, or null on failure. +pub fn attachCurrentThread(vm: JavaVM) ?JNIEnv { + const F = *const fn (JavaVM, *?*anyopaque, ?*anyopaque) callconv(.c) jint; + var env: ?*anyopaque = null; + const rc = vmSlot(vm, InvokeOrd.AttachCurrentThread, F)(vm, &env, null); + if (rc != JNI_OK) return null; + const e = env orelse return null; + return @ptrCast(@alignCast(e)); +} + +/// `(*vm)->DetachCurrentThread(vm)` — MUST be called before a thread that +/// attached itself exits, or the JVM will abort. +pub fn detachCurrentThread(vm: JavaVM) void { + const F = *const fn (JavaVM) callconv(.c) jint; + _ = vmSlot(vm, InvokeOrd.DetachCurrentThread, F)(vm); +} + +//============================================================================== +// Tests (host-runnable — no Android required) +//============================================================================== + +test "jvalue is the 8-byte JNI union" { + // The NDK defines jvalue as an 8-byte union; the ...A call variants index + // it as a contiguous array, so size and alignment must be exactly 8. + try std.testing.expectEqual(@as(usize, 8), @sizeOf(jvalue)); + try std.testing.expectEqual(@as(usize, 8), @alignOf(jvalue)); +} + +test "jvalue constructors select the right union member" { + try std.testing.expectEqual(@as(jlong, 0x0123_4567_89AB_CDEF), vLong(0x0123_4567_89AB_CDEF).j); + try std.testing.expectEqual(@as(jint, -7), vInt(-7).i); + try std.testing.expect(vObj(null).l == null); +} + +test "ordinals are monotonic and within the JNI table" { + // The JNINativeInterface table has 233 entries (0..232). A used ordinal + // landing outside that range would mean a transcription error. + const ords = [_]usize{ + Ord.ExceptionClear, Ord.NewGlobalRef, Ord.DeleteGlobalRef, + Ord.NewObjectA, Ord.GetObjectClass, Ord.GetMethodID, + Ord.CallObjectMethodA, Ord.CallVoidMethodA, Ord.GetStaticMethodID, + Ord.CallStaticVoidMethodA, Ord.NewStringUTF, Ord.GetStringUTFChars, + Ord.ReleaseStringUTFChars, Ord.GetJavaVM, Ord.ExceptionCheck, + }; + for (ords) |o| try std.testing.expect(o <= 232); + // A couple of fixed relationships from the spec (…Method / …MethodV / …MethodA + // are consecutive triples), used here as a transcription self-check. + try std.testing.expectEqual(Ord.CallVoidMethodA, @as(usize, 63)); + try std.testing.expectEqual(Ord.CallStaticVoidMethodA, @as(usize, 143)); +} + +test "every JNI wrapper type-checks on the host (compiled, not invoked)" { + // Taking the address of each wrapper forces full semantic analysis, so a + // bad function-pointer cast or signature surfaces on the host CI runner — + // even though webview_android.zig (which uses them) only compiles for an + // *-android target. The wrappers are never CALLED here: there is no live + // JNIEnv on the host. + const refs = .{ + &findClass, &getObjectClass, &getMethodID, + &getStaticMethodID, &newObject, &callVoidMethod, + &callObjectMethod, &callStaticVoidMethod, &newStringUTF, + &getStringUTFChars, &releaseStringUTFChars, &newGlobalRef, + &deleteGlobalRef, &exceptionCheck, &exceptionClear, + &clearPendingException, &getJavaVM, &getEnv, + &attachCurrentThread, &detachCurrentThread, + }; + inline for (refs) |r| { + try std.testing.expect(@intFromPtr(r) != 0); + } +} diff --git a/src/interface/ffi/src/main.zig b/src/interface/ffi/src/main.zig index 2acf51e..12b0e2d 100644 --- a/src/interface/ffi/src/main.zig +++ b/src/interface/ffi/src/main.zig @@ -100,12 +100,21 @@ const BUILD_INFO = "Gossamer " ++ VERSION ++ " built with Zig " ++ @import("buil /// Platform-specific webview implementation. /// Compile-time dispatch — no runtime overhead. -const platform = switch (builtin.os.tag) { +/// +/// Android is detected BEFORE the os-tag switch: an Android target reports +/// `os.tag == .linux` (it is a Linux kernel), so dispatching on os.tag alone +/// wrongly selects the GTK backend and links WebKitGTK into an .so that can +/// never load on a phone. The `abi == .android` guard routes it to the JNI +/// WebView backend instead. The comptime `if` means the android import is not +/// evaluated on non-android targets, so the desktop paths are unchanged. +const platform = if (builtin.abi == .android) + @import("webview_android.zig") +else switch (builtin.os.tag) { .linux, .freebsd, .openbsd, .netbsd => @import("webview_gtk.zig"), .macos => @import("webview_cocoa.zig"), .windows => @import("webview_win32.zig"), .ios => @import("webview_ios.zig"), - else => @compileError("Gossamer: unsupported platform. Supported: linux, BSD, macOS, Windows, iOS. Android requires NDK target."), + else => @compileError("Gossamer: unsupported platform. Supported: Linux, BSD, macOS, Windows, iOS, Android (NDK)."), }; //============================================================================== diff --git a/src/interface/ffi/src/webview_android.zig b/src/interface/ffi/src/webview_android.zig index 33a38df..5de2225 100644 --- a/src/interface/ffi/src/webview_android.zig +++ b/src/interface/ffi/src/webview_android.zig @@ -3,70 +3,65 @@ // // Gossamer — Android WebView Platform Implementation // -// Provides platform-specific webview operations for Android using JNI -// to interface with android.webkit.WebView. +// Platform-specific webview operations for Android, driven over JNI against +// android.webkit.WebView. Compiled when targeting *-linux-android via the NDK +// (selected in main.zig by `builtin.abi == .android`). // -// This file is compiled when targeting aarch64-linux-android or -// x86_64-linux-android via the Android NDK. +// This file was rewritten to call REAL JNI. The previous version declared +// `extern fn jni_FindClass(...)` and friends — symbols defined nowhere, so the +// shell could never link. All JNI now goes through `jni.zig`, which models the +// per-thread JNIEnv function table directly (see that file for the rationale). // -// Dependencies: -// Android NDK (libjnigraphics, libandroid, liblog) -// Android SDK WebView component (API level 24+) +// Correctness fixes folded into the rewrite: +// * nativeInit now promotes the Activity/WebView to GLOBAL refs. The old code +// stored the raw local refs Java handed in; those are invalid the moment +// nativeInit returns, so every later call dereferenced freed handles. +// * The process JavaVM is cached so native threads (the run() loop, async IPC +// workers) can attach and obtain a valid env instead of reusing a stored +// env from another thread (JNIEnv is strictly thread-local). +// * IPC registration no longer constructs a second GossamerBridge and calls +// addJavascriptInterface a second time — the generated GossamerActivity +// already registers the bridge in onCreate. The native side only records +// the dispatch handle, removing the double-registration. // -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// Dependencies at link time (NDK): liblog, libandroid. No webview .so is linked +// — android.webkit.WebView is reached entirely through JNI. const std = @import("std"); - -//============================================================================== -// JNI Type Definitions -//============================================================================== - -/// JNI environment pointer — wraps the function table used for all JNI calls. -/// On Android, each thread has its own JNIEnv obtained via AttachCurrentThread. -const JNIEnv = anyopaque; -/// Java object reference (opaque) -const jobject = ?*anyopaque; -/// Java class reference (opaque) -const jclass = ?*anyopaque; -/// Java method ID (opaque) -const jmethodID = ?*anyopaque; -/// Java string reference (opaque) -const jstring = ?*anyopaque; - -/// JNI function table pointers — accessed via double indirection on JNIEnv. -/// These are declared as extern C functions for Zig to call through. -extern fn jni_FindClass(env: *JNIEnv, name: [*:0]const u8) jclass; -extern fn jni_GetMethodID(env: *JNIEnv, cls: jclass, name: [*:0]const u8, sig: [*:0]const u8) jmethodID; -extern fn jni_NewStringUTF(env: *JNIEnv, str: [*:0]const u8) jstring; -extern fn jni_CallVoidMethod(env: *JNIEnv, obj: jobject, method: jmethodID, ...) void; -extern fn jni_NewObject(env: *JNIEnv, cls: jclass, method: jmethodID, ...) jobject; -extern fn jni_DeleteGlobalRef(env: *JNIEnv, ref: jobject) void; -extern fn jni_NewGlobalRef(env: *JNIEnv, ref: jobject) jobject; -extern fn jni_GetStringUTFChars(env: *JNIEnv, str: jstring, isCopy: ?*u8) ?[*:0]const u8; -extern fn jni_ReleaseStringUTFChars(env: *JNIEnv, str: jstring, chars: [*:0]const u8) void; +const jni = @import("jni.zig"); +const comp = @import("android_components.zig"); + +/// Force the non-UI component hosts (Service/Receiver/Widget) and their +/// `gossamer_*_bind` exports into the Android image. They are reachable only +/// through this platform module, so referencing them here is what makes their +/// `export fn`s part of libgossamer.so on Android — and only on Android. +comptime { + _ = @import("android_service.zig"); + _ = @import("android_receiver.zig"); + _ = @import("android_widget.zig"); +} /// Platform-specific webview state for Android. /// Stored inside GossamerHandle.webview. pub const WebviewState = struct { - /// JNIEnv pointer — valid only on the thread that attached it - jni_env: ?*JNIEnv, - /// Global reference to the Android Activity (jobject) - activity: jobject, - /// Global reference to the Android WebView (jobject) - webview: jobject, - /// Cached class ref for android.webkit.WebView - webview_class: jclass, - /// Cached method IDs for WebView methods - mid_loadData: jmethodID, - mid_loadUrl: jmethodID, - mid_evaluateJavascript: jmethodID, - mid_addJavascriptInterface: jmethodID, - /// Cached method ID for Activity.setTitle - mid_setTitle: jmethodID, - /// Whether JNI has been initialised + /// JNIEnv captured at nativeInit — valid on the JVM UI thread. Off-thread + /// callers must obtain their own env via `currentEnv()` instead. + jni_env: ?jni.JNIEnv, + /// Global reference to the Android Activity (jobject). + activity: jni.jobject, + /// Global reference to the Android WebView (jobject). + webview: jni.jobject, + /// Cached class ref for android.webkit.WebView. + webview_class: jni.jclass, + /// Cached method IDs for WebView methods. + mid_loadData: jni.jmethodID, + mid_loadUrl: jni.jmethodID, + mid_evaluateJavascript: jni.jmethodID, + /// Cached method ID for Activity.setTitle. + mid_setTitle: jni.jmethodID, + /// Whether JNI has been initialised. jni_initialized: bool, - /// Shutdown signal — set by Java when Activity.onDestroy fires + /// Shutdown signal — set by Java when Activity.onDestroy fires. shutdown: bool, }; @@ -81,16 +76,18 @@ pub const PlatformError = error{ /// Opaque reference to GossamerHandle from main.zig. const GossamerHandle = @import("main.zig").GossamerHandle; -/// Create a new Android WebView. -/// -/// On Android, the Activity must already exist (created by the Java launcher). -/// This function attaches to the existing Activity's WebView via JNI and -/// caches all method IDs for fast subsequent calls. +/// Obtain a JNIEnv valid on the CURRENT thread: prefer the already-attached +/// env, otherwise attach via the cached JavaVM. Returns null if no VM is known. +fn currentEnv() ?jni.JNIEnv { + const vm = android_vm orelse return android_jni_env; + return jni.getEnv(vm, jni.JNI_VERSION_1_6) orelse jni.attachCurrentThread(vm); +} + +/// Create a new Android WebView binding. /// -/// NOTE: Android apps are launched by the Java runtime, not by native code. -/// The native library is loaded via System.loadLibrary("gossamer") from -/// a GossamerActivity Java class. The JNIEnv and Activity reference are -/// passed to JNI_OnLoad / native method calls. +/// On Android the Activity and WebView already exist (constructed by the +/// generated GossamerActivity). This attaches to them via JNI and caches the +/// method IDs used on the hot path. pub fn create( title: [*:0]const u8, width: u32, @@ -104,8 +101,8 @@ pub fn create( fullscreen: bool, visible: bool, ) PlatformError!WebviewState { - _ = title; // Set via Activity.setTitle after creation - _ = width; // Android fills the Activity + _ = title; // Applied via Activity.setTitle after creation. + _ = width; // Android fills the Activity. _ = height; _ = min_width; _ = min_height; @@ -116,44 +113,30 @@ pub fn create( _ = fullscreen; _ = visible; - // Check if JNI references have been provided by the Java launcher const env = android_jni_env orelse return PlatformError.JniInitFailed; const activity = android_activity orelse return PlatformError.JniInitFailed; const webview = android_webview orelse return PlatformError.WebviewCreateFailed; - // Cache WebView class and method IDs for performance - const wv_cls = jni_FindClass(env, "android/webkit/WebView"); + const wv_cls = jni.findClass(env, "android/webkit/WebView"); if (wv_cls == null) return PlatformError.WebviewCreateFailed; - const mid_loadData = jni_GetMethodID( - env, - wv_cls, - "loadData", - "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V", - ); - const mid_loadUrl = jni_GetMethodID( + const mid_loadData = jni.getMethodID( env, wv_cls, - "loadUrl", - "(Ljava/lang/String;)V", + "loadDataWithBaseURL", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V", ); - const mid_evaluateJavascript = jni_GetMethodID( + const mid_loadUrl = jni.getMethodID(env, wv_cls, "loadUrl", "(Ljava/lang/String;)V"); + const mid_eval = jni.getMethodID( env, wv_cls, "evaluateJavascript", "(Ljava/lang/String;Landroid/webkit/ValueCallback;)V", ); - const mid_addJsi = jni_GetMethodID( - env, - wv_cls, - "addJavascriptInterface", - "(Ljava/lang/Object;Ljava/lang/String;)V", - ); - // Cache Activity.setTitle method ID - const act_cls = jni_FindClass(env, "android/app/Activity"); + const act_cls = jni.findClass(env, "android/app/Activity"); const mid_setTitle = if (act_cls != null) - jni_GetMethodID(env, act_cls, "setTitle", "(Ljava/lang/CharSequence;)V") + jni.getMethodID(env, act_cls, "setTitle", "(Ljava/lang/CharSequence;)V") else null; @@ -164,99 +147,91 @@ pub fn create( .webview_class = wv_cls, .mid_loadData = mid_loadData, .mid_loadUrl = mid_loadUrl, - .mid_evaluateJavascript = mid_evaluateJavascript, - .mid_addJavascriptInterface = mid_addJsi, + .mid_evaluateJavascript = mid_eval, .mid_setTitle = mid_setTitle, .jni_initialized = true, .shutdown = false, }; } -/// Load HTML content into the webview. -/// JNI call: webView.loadData(html, "text/html", "UTF-8") +/// Load HTML content. JNI: webView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null). +/// loadDataWithBaseURL is used over loadData so that document.origin is stable +/// and the JS bridge / fetch behave consistently. pub fn loadHTML(state: *WebviewState, html: [*:0]const u8) PlatformError!void { const env = state.jni_env orelse return PlatformError.OperationFailed; const webview = state.webview orelse return PlatformError.OperationFailed; const mid = state.mid_loadData orelse return PlatformError.OperationFailed; - const j_html = jni_NewStringUTF(env, html); - const j_mime = jni_NewStringUTF(env, "text/html"); - const j_enc = jni_NewStringUTF(env, "UTF-8"); - - if (j_html == null or j_mime == null or j_enc == null) { - return PlatformError.OperationFailed; - } - - jni_CallVoidMethod(env, webview, mid, j_html, j_mime, j_enc); + const j_html = jni.newStringUTF(env, html); + const j_mime = jni.newStringUTF(env, "text/html"); + const j_enc = jni.newStringUTF(env, "UTF-8"); + if (j_html == null or j_mime == null or j_enc == null) return PlatformError.OperationFailed; + + jni.callVoidMethod(env, webview, mid, &.{ + jni.vObj(null), // baseUrl + jni.vObj(j_html), + jni.vObj(j_mime), + jni.vObj(j_enc), + jni.vObj(null), // historyUrl + }); + _ = jni.clearPendingException(env); } -/// Navigate to a URL. -/// JNI call: webView.loadUrl(url) +/// Navigate to a URL. JNI: webView.loadUrl(url). pub fn navigate(state: *WebviewState, url: [*:0]const u8) PlatformError!void { const env = state.jni_env orelse return PlatformError.OperationFailed; const webview = state.webview orelse return PlatformError.OperationFailed; const mid = state.mid_loadUrl orelse return PlatformError.OperationFailed; - const j_url = jni_NewStringUTF(env, url); + const j_url = jni.newStringUTF(env, url); if (j_url == null) return PlatformError.OperationFailed; - - jni_CallVoidMethod(env, webview, mid, j_url); + jni.callVoidMethod(env, webview, mid, &.{jni.vObj(j_url)}); + _ = jni.clearPendingException(env); } -/// Evaluate JavaScript in the webview context. -/// JNI call: webView.evaluateJavascript(js, null) -/// Requires API level 19+ (minSdk 24 guarantees this). +/// Evaluate JavaScript. JNI: webView.evaluateJavascript(js, null). +/// Uses an env valid on the current thread (the JS bridge callback runs on a +/// binder thread, not the UI thread). pub fn eval(state: *WebviewState, js: [*:0]const u8) PlatformError!void { - const env = state.jni_env orelse return PlatformError.OperationFailed; + const env = currentEnv() orelse return PlatformError.OperationFailed; const webview = state.webview orelse return PlatformError.OperationFailed; const mid = state.mid_evaluateJavascript orelse return PlatformError.OperationFailed; - const j_js = jni_NewStringUTF(env, js); + const j_js = jni.newStringUTF(env, js); if (j_js == null) return PlatformError.OperationFailed; - - // null ValueCallback — fire and forget (response comes via IPC bridge) - jni_CallVoidMethod(env, webview, mid, j_js, @as(jobject, null)); + jni.callVoidMethod(env, webview, mid, &.{ jni.vObj(j_js), jni.vObj(null) }); + _ = jni.clearPendingException(env); } -/// Set the window title (Activity title on Android). -/// JNI call: activity.setTitle(title) +/// Set the window title (Activity title on Android). JNI: activity.setTitle(title). pub fn setTitle(state: *WebviewState, title: [*:0]const u8) PlatformError!void { const env = state.jni_env orelse return PlatformError.OperationFailed; const activity = state.activity orelse return PlatformError.OperationFailed; const mid = state.mid_setTitle orelse return PlatformError.OperationFailed; - const j_title = jni_NewStringUTF(env, title); + const j_title = jni.newStringUTF(env, title); if (j_title == null) return PlatformError.OperationFailed; - - jni_CallVoidMethod(env, activity, mid, j_title); + jni.callVoidMethod(env, activity, mid, &.{jni.vObj(j_title)}); + _ = jni.clearPendingException(env); } -/// Resize the window (no-op on Android — WebView fills the Activity). -pub fn resize(_: *WebviewState, _: u32, _: u32) PlatformError!void { - // Android WebView fills the Activity — resize is not applicable -} +/// Resize is a no-op on Android — the WebView fills the Activity. +pub fn resize(_: *WebviewState, _: u32, _: u32) PlatformError!void {} -/// Window visibility/state controls are not supported on Android. +/// Window visibility/state controls are not applicable to a single-Activity +/// Android shell. pub fn show(_: *WebviewState) PlatformError!void { return PlatformError.OperationFailed; } - -/// Window visibility/state controls are not supported on Android. pub fn hide(_: *WebviewState) PlatformError!void { return PlatformError.OperationFailed; } - -/// Window visibility/state controls are not supported on Android. pub fn minimize(_: *WebviewState) PlatformError!void { return PlatformError.OperationFailed; } - -/// Window visibility/state controls are not supported on Android. pub fn maximize(_: *WebviewState) PlatformError!void { return PlatformError.OperationFailed; } - -/// Window visibility/state controls are not supported on Android. pub fn restore(_: *WebviewState) PlatformError!void { return PlatformError.OperationFailed; } @@ -270,7 +245,8 @@ pub fn getScreenSize(_: *WebviewState) [2]u32 { return .{ android_screen_width, android_screen_height }; } -/// Register a persistent user script. +/// User scripts are not yet wired on Android (would require WebViewClient +/// onPageFinished injection in the generated Java). pub fn addUserScript(_: *WebviewState, _: [*:0]const u8) PlatformError!void {} /// Z-order and move are not applicable on Android (single-activity model). @@ -278,34 +254,25 @@ pub fn raise(_: *WebviewState) PlatformError!void {} pub fn lower(_: *WebviewState) PlatformError!void {} pub fn moveTo(_: *WebviewState, _: i32, _: i32) PlatformError!void {} -/// Requesting close is not supported on Android from the native shell layer. +/// Requesting close from the native shell layer is not supported on Android. pub fn requestClose(_: *WebviewState) PlatformError!void { return PlatformError.OperationFailed; } -/// Run the event loop. -/// On Android, the Java runtime owns the event loop. This function blocks -/// by polling the shutdown flag, which is set when Activity.onDestroy fires. +/// Run the event loop. On Android the JVM owns the loop; this blocks the +/// native run-thread until Activity.onDestroy sets the shutdown flag. pub fn run(state: *WebviewState) void { - // Block until the Java side signals shutdown. - // On Android, the native library stays loaded as long as the Activity - // is alive. We poll with a short sleep to avoid burning CPU. while (!state.shutdown) { - std.time.sleep(50 * std.time.ns_per_ms); // 50ms poll + std.time.sleep(50 * std.time.ns_per_ms); } } -/// Destroy the webview and release JNI global references. +/// Destroy the webview binding and release JNI global references. pub fn destroy(state: *WebviewState) void { if (state.jni_initialized) { - if (state.jni_env) |env| { - // Delete global references to prevent Java-side memory leaks - if (state.activity) |activity| { - jni_DeleteGlobalRef(env, activity); - } - if (state.webview) |webview| { - jni_DeleteGlobalRef(env, webview); - } + if (currentEnv()) |env| { + if (state.activity) |activity| jni.deleteGlobalRef(env, activity); + if (state.webview) |webview| jni.deleteGlobalRef(env, webview); } state.jni_env = null; state.activity = null; @@ -315,87 +282,52 @@ pub fn destroy(state: *WebviewState) void { } } -/// Register IPC handler for Android WebView. +/// Register the IPC dispatch handle for the Android WebView. /// -/// Uses WebView.addJavascriptInterface() to expose a "GossamerBridge" -/// object to JavaScript. When JS calls GossamerBridge.postMessage(msg), -/// the registered Java callback dispatches to handle.bindings. -/// -/// The Java-side GossamerBridge class must exist in the APK and implement -/// @JavascriptInterface void postMessage(String msg). This is defined in -/// io.gossamer.GossamerBridge.java (shipped with the Gossamer Android SDK). -pub fn registerIPCHandler(state: *WebviewState, handle: *GossamerHandle) PlatformError!void { - const env = state.jni_env orelse return PlatformError.OperationFailed; - const webview = state.webview orelse return PlatformError.OperationFailed; - const mid = state.mid_addJavascriptInterface orelse return PlatformError.OperationFailed; - - // Store handle reference for the Java callback to use +/// The generated GossamerActivity already registered the GossamerBridge JS +/// interface in onCreate, so this only records the native handle that +/// GossamerBridge.nativePostMessage dispatches against. (The old code +/// constructed a second bridge and re-registered it — a double registration +/// that this removes.) +pub fn registerIPCHandler(_: *WebviewState, handle: *GossamerHandle) PlatformError!void { ipc_handle = handle; - - // Find the GossamerBridge Java class (must be in the APK) - const bridge_cls = jni_FindClass(env, "io/gossamer/GossamerBridge"); - if (bridge_cls == null) return PlatformError.OperationFailed; - - // Locate constructor: GossamerBridge(long nativePtr) - const bridge_init = jni_GetMethodID(env, bridge_cls, "", "(J)V"); - if (bridge_init == null) return PlatformError.OperationFailed; - - // Construct a new GossamerBridge instance via NewObject, passing the - // native handle pointer as the long constructor argument. - // jni_NewObject is the correct JNI call for constructing new objects; - // jni_CallObjectMethod would call an instance method, not a constructor. - const native_ptr: i64 = @intCast(@intFromPtr(handle)); - const bridge = jni_NewObject(env, bridge_cls, bridge_init, native_ptr); - if (bridge == null) return PlatformError.OperationFailed; - - // webView.addJavascriptInterface(bridge, "GossamerBridge") - const j_name = jni_NewStringUTF(env, "GossamerBridge"); - if (j_name == null) return PlatformError.OperationFailed; - - jni_CallVoidMethod(env, webview, mid, bridge, j_name); } //============================================================================== // IPC Message Handling //============================================================================== -/// Thread-local handle reference for the Java callback. +/// Dispatch handle for the Java bridge callback. var ipc_handle: ?*GossamerHandle = null; -/// Called from Java GossamerBridge.postMessage(@JavascriptInterface). -/// The Java side receives the JSON string from the JS bridge and forwards -/// it here via JNI. We parse the message, dispatch to the bound callback, -/// and send the response back via evaluateJavascript. +/// Called from Java GossamerBridge.postMessage (@JavascriptInterface). +/// Parses the JSON IPC message, dispatches to the bound callback, and sends the +/// response back via evaluateJavascript. export fn Java_io_gossamer_GossamerBridge_nativePostMessage( - env: ?*JNIEnv, - _: jobject, // this (GossamerBridge instance) - message: jstring, + env: jni.JNIEnv, + _: jni.jobject, // this (GossamerBridge instance) + message: jni.jstring, ) void { const handle = ipc_handle orelse return; - const jni = env orelse return; const msg = message orelse return; - // Extract UTF-8 string from Java String - const msg_chars = jni_GetStringUTFChars(jni, msg, null) orelse return; - defer jni_ReleaseStringUTFChars(jni, msg, msg_chars); + const msg_chars = jni.getStringUTFChars(env, msg) orelse return; + defer jni.releaseStringUTFChars(env, msg, msg_chars); const msg_slice = std.mem.span(msg_chars); - // Parse JSON fields: id, name, payload - const id = extractJsonField(msg_slice, "id") orelse return; - const name = extractJsonField(msg_slice, "name") orelse return; - const payload = extractJsonField(msg_slice, "payload") orelse ""; + const id = comp.extractJsonField(msg_slice, "id") orelse return; + const name = comp.extractJsonField(msg_slice, "name") orelse return; + const payload = comp.extractJsonField(msg_slice, "payload") orelse ""; - // Look up the bound callback - const callback = handle.bindings.get(name) orelse { + const entry = handle.bindings.get(name) orelse { sendIPCError(handle, id, "No handler bound for command"); return; }; - // Invoke the callback with the payload const allocator = std.heap.c_allocator; const payload_z = allocator.dupeZ(u8, payload) catch return; defer allocator.free(payload_z); - const response_ptr = callback(payload_z); + const response_ptr = entry.callback(payload_z, entry.user_data); const response = std.mem.span(response_ptr); sendIPCResponse(handle, id, response); } @@ -428,21 +360,6 @@ fn sendIPCError(handle: *GossamerHandle, id: []const u8, msg_text: []const u8) v eval(&handle.webview, js) catch {}; } -fn extractJsonField(json: []const u8, key: []const u8) ?[]const u8 { - const allocator = std.heap.c_allocator; - const search = std.fmt.allocPrint(allocator, "\"{s}\":\"", .{key}) catch return null; - defer allocator.free(search); - const start_idx = std.mem.indexOf(u8, json, search) orelse return null; - const value_start = start_idx + search.len; - var i: usize = value_start; - while (i < json.len) : (i += 1) { - if (json[i] == '"' and (i == 0 or json[i - 1] != '\\')) { - return json[value_start..i]; - } - } - return null; -} - fn escapeForJS(allocator: std.mem.Allocator, input: []const u8) ![]u8 { var result = std.ArrayListUnmanaged(u8){}; errdefer result.deinit(allocator); @@ -460,54 +377,49 @@ fn escapeForJS(allocator: std.mem.Allocator, input: []const u8) ![]u8 { } //============================================================================== -// JNI Entry Points +// JNI Entry Points (Activity lifecycle) //============================================================================== -/// Thread-local JNI references set by the Java launcher. -/// These are populated when the GossamerActivity calls native methods. -var android_jni_env: ?*JNIEnv = null; -var android_activity: jobject = null; -var android_webview: jobject = null; -/// Screen pixel dimensions cached from nativeInit — populated by Java before use. +/// Cached references set by the Java launcher at nativeInit. +var android_jni_env: ?jni.JNIEnv = null; +var android_vm: ?jni.JavaVM = null; +var android_activity: jni.jobject = null; +var android_webview: jni.jobject = null; +/// Screen pixel dimensions cached from nativeInit (populated by Java). var android_screen_width: u32 = 1080; var android_screen_height: u32 = 1920; -/// Called by Java via JNI to provide the Activity, WebView, and screen dimensions. +/// Called by Java to hand over the Activity, WebView, and screen dimensions. /// -/// Java signature: -/// native void nativeInit(Activity activity, WebView webview, -/// int screenWidth, int screenHeight) +/// Java signature (generated GossamerActivity): +/// native void nativeInit(Activity activity, WebView webview, int w, int h) /// -/// The Java side must create global references before calling this: -/// nativeInit(NewGlobalRef(activity), NewGlobalRef(webview), -/// displayMetrics.widthPixels, displayMetrics.heightPixels) +/// The local refs Java passes are valid only for this call, so we promote them +/// to GLOBAL refs before storing. We also cache the process JavaVM so other +/// threads can attach. export fn Java_io_gossamer_GossamerActivity_nativeInit( - env: ?*JNIEnv, - _: jobject, // this (GossamerActivity) - activity: jobject, - webview: jobject, - screen_width: i32, - screen_height: i32, + env: jni.JNIEnv, + _: jni.jobject, // this (GossamerActivity) + activity: jni.jobject, + webview: jni.jobject, + screen_width: jni.jint, + screen_height: jni.jint, ) void { android_jni_env = env; - android_activity = activity; - android_webview = webview; - // Cache screen dimensions so getScreenSize() can return meaningful values - // without additional JNI calls on every query. + android_vm = jni.getJavaVM(env); + android_activity = jni.newGlobalRef(env, activity); + android_webview = jni.newGlobalRef(env, webview); if (screen_width > 0) android_screen_width = @intCast(screen_width); if (screen_height > 0) android_screen_height = @intCast(screen_height); } -/// Called by Java when the Activity is destroyed. -/// Signals the native run loop to exit and clears references. +/// Called by Java when the Activity is destroyed. Signals the native run loop +/// to exit and clears references. export fn Java_io_gossamer_GossamerActivity_nativeDestroy( - _: ?*JNIEnv, - _: jobject, + _: jni.JNIEnv, + _: jni.jobject, ) void { - // Signal shutdown to the run() poll loop - if (ipc_handle) |handle| { - handle.webview.shutdown = true; - } + if (ipc_handle) |handle| handle.webview.shutdown = true; android_jni_env = null; android_activity = null; android_webview = null; From d380dd96ddc2b6b4e20edc26f7690ad49684e147 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 14:04:32 +0000 Subject: [PATCH 02/19] feat(android): native Service/Receiver/Widget component hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grow gossamer from 'a WebView in an Activity' to hosting the non-UI Android components an app needs, as first-class capabilities — not left for each app to hand-roll in Kotlin (the neurophone#83 debt). - android_components.zig: process-global handler registry + dispatch (a receiver can fire with no Activity/webview alive). JSON event in, key\tvalue directive out. Reuses the webview IPC callback ABI, so apps register pure Rust/Zig/Idris handlers and never touch JNI. - android_service.zig: foreground Service host + gossamer_service_bind. Directive drives the foreground notification + START_STICKY. - android_receiver.zig: BroadcastReceiver host + gossamer_receiver_bind, keyed by Intent action (boot-completed, custom actions). - android_widget.zig: AppWidgetProvider host + gossamer_widget_bind. Directive is a RemoteViews render script applied by the generated Java. - Host-runnable tests for registry/dispatch/parse logic, wired into zig build test. https://claude.ai/code/session_01GsJX13UjwiBk9hkddqvYMh --- src/interface/ffi/src/android_components.zig | 265 ++++++++++++++++++ src/interface/ffi/src/android_receiver.zig | 97 +++++++ src/interface/ffi/src/android_service.zig | 124 ++++++++ src/interface/ffi/src/android_widget.zig | 133 +++++++++ .../ffi/test/android_components_test.zig | 24 ++ 5 files changed, 643 insertions(+) create mode 100644 src/interface/ffi/src/android_components.zig create mode 100644 src/interface/ffi/src/android_receiver.zig create mode 100644 src/interface/ffi/src/android_service.zig create mode 100644 src/interface/ffi/src/android_widget.zig create mode 100644 src/interface/ffi/test/android_components_test.zig diff --git a/src/interface/ffi/src/android_components.zig b/src/interface/ffi/src/android_components.zig new file mode 100644 index 0000000..492b5b7 --- /dev/null +++ b/src/interface/ffi/src/android_components.zig @@ -0,0 +1,265 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Gossamer — Android non-UI component dispatch core +// +// Gossamer hosts a WebView inside an Activity, but a real Android app also +// needs background components that have NO webview and NO Activity in scope: +// +// * a foreground Service (long-running work, notification) +// * a BroadcastReceiver (boot-completed, custom actions) +// * an AppWidgetProvider (home-screen widget) +// +// This module is the shared dispatch core those three hosts sit on. It lets a +// downstream app register PURE handlers — the exact same C-ABI callback shape +// already used for webview IPC (`fn(json) -> directive`) — against component +// lifecycle events, WITHOUT the app touching JNI. Gossamer owns every JNI call; +// the app writes Rust/Idris/Zig that receives a JSON event string and returns a +// directive string. +// +// Two wire formats cross this boundary, each trivially parseable by the side +// that must read it: +// +// * INBOUND event (JVM -> handler): a flat JSON object, e.g. +// {"event":"onStartCommand","action":"","startId":"1"} +// Native already has a minimal JSON field reader, so JSON costs nothing +// here and stays consistent with the webview IPC protocol. +// +// * OUTBOUND directive (handler -> JVM): newline/tab `key\tvalue` records, +// e.g. +// foreground\t1 +// title\tNeuroPhone +// text\tListening +// fgType\tdataSync +// sticky\t1 +// The GENERATED Java parses this with a four-line splitter — no JSON +// reader, no escaping ambiguity, nothing for a downstream app to hand-roll. +// +// Registration is process-global on purpose: a BroadcastReceiver can fire when +// no Activity (hence no GossamerHandle) exists. Handlers are typically bound +// once from the app's `JNI_OnLoad` / init and live for the process. +// +// Pure Zig: compiles on any target, so its `test` blocks run on host CI. + +const std = @import("std"); + +/// Component callback ABI — identical to `main.BindingCallback`. A handler +/// receives a NUL-terminated JSON event string (plus its registration user +/// data) and returns a NUL-terminated directive string. The returned pointer +/// is owned by the handler (static, arena, or leak-by-design — same contract +/// as the existing webview IPC callbacks); gossamer only reads it. +pub const ComponentCallback = *const fn ([*:0]const u8, ?*anyopaque) callconv(.c) [*:0]const u8; + +/// The three component namespaces. Kept distinct so their lifecycles (and the +/// Idris linear models in Gossamer.ABI.AndroidComponents) stay separable. +pub const Component = enum { service, receiver, widget }; + +/// Result codes mirror `main.Result` numeric values so the C ABI is uniform. +pub const BindResult = enum(c_int) { + ok = 0, + @"error" = 1, + invalid_param = 2, + out_of_memory = 3, +}; + +const MAX_HANDLERS_PER_COMPONENT = 64; +const MAX_KEY_LEN = 128; + +const Handler = struct { + used: bool = false, + key_buf: [MAX_KEY_LEN]u8 = undefined, + key_len: usize = 0, + callback: ?ComponentCallback = null, + user_data: ?*anyopaque = null, + + fn keyMatches(self: *const Handler, key: []const u8) bool { + return self.used and self.key_len == key.len and + std.mem.eql(u8, self.key_buf[0..self.key_len], key); + } +}; + +const Registry = struct { + slots: [MAX_HANDLERS_PER_COMPONENT]Handler = [_]Handler{.{}} ** MAX_HANDLERS_PER_COMPONENT, + + fn find(self: *Registry, key: []const u8) ?*Handler { + for (&self.slots) |*h| { + if (h.keyMatches(key)) return h; + } + return null; + } + + fn freeSlot(self: *Registry) ?*Handler { + for (&self.slots) |*h| { + if (!h.used) return h; + } + return null; + } + + fn put(self: *Registry, key: []const u8, cb: ComponentCallback, user: ?*anyopaque) BindResult { + if (key.len == 0 or key.len > MAX_KEY_LEN) return .invalid_param; + // Re-binding the same key overwrites in place (idempotent registration). + const dst = self.find(key) orelse (self.freeSlot() orelse return .out_of_memory); + @memcpy(dst.key_buf[0..key.len], key); + dst.key_len = key.len; + dst.callback = cb; + dst.user_data = user; + dst.used = true; + return .ok; + } + + fn count(self: *Registry) usize { + var n: usize = 0; + for (&self.slots) |*h| { + if (h.used) n += 1; + } + return n; + } + + fn clear(self: *Registry) void { + for (&self.slots) |*h| h.* = .{}; + } +}; + +var g_service = Registry{}; +var g_receiver = Registry{}; +var g_widget = Registry{}; + +fn registryFor(component: Component) *Registry { + return switch (component) { + .service => &g_service, + .receiver => &g_receiver, + .widget => &g_widget, + }; +} + +/// Bind a handler for `key` on `component`. `key` is copied (the caller may +/// free it after this returns). Returns `.ok` on success. +pub fn bind(component: Component, key: []const u8, cb: ComponentCallback, user: ?*anyopaque) BindResult { + return registryFor(component).put(key, cb, user); +} + +/// Dispatch an event to the handler registered under `key`. Returns the +/// directive the handler produced (borrowed, handler-owned), or null when no +/// handler is bound for `key` — in which case the JVM host applies its default +/// behaviour (e.g. START_STICKY with no foreground change). +pub fn dispatch(component: Component, key: []const u8, event_json: [*:0]const u8) ?[*:0]const u8 { + const h = registryFor(component).find(key) orelse return null; + const cb = h.callback orelse return null; + return cb(event_json, h.user_data); +} + +/// Number of handlers currently bound for a component (diagnostics/tests). +pub fn handlerCount(component: Component) usize { + return registryFor(component).count(); +} + +/// Drop every binding for a component. Exposed for tests and for a clean +/// teardown path; not part of the app-facing ABI. +pub fn resetForTest(component: Component) void { + registryFor(component).clear(); +} + +//============================================================================== +// Shared parsing helpers (reused by the webview bridge and the component hosts) +//============================================================================== + +/// Extract a string field from a flat `{"key":"value", ...}` JSON object. +/// Minimal by design — the IPC/event envelopes gossamer emits are flat and +/// machine-generated, so a full JSON parser is unnecessary at this boundary. +pub fn extractJsonField(json: []const u8, key: []const u8) ?[]const u8 { + var search_buf: [MAX_KEY_LEN + 4]u8 = undefined; + if (key.len + 3 > search_buf.len) return null; + search_buf[0] = '"'; + @memcpy(search_buf[1 .. 1 + key.len], key); + search_buf[1 + key.len] = '"'; + search_buf[2 + key.len] = ':'; + const needle = search_buf[0 .. 3 + key.len]; + + const at = std.mem.indexOf(u8, json, needle) orelse return null; + var i = at + needle.len; + // Skip optional whitespace and the opening quote of the value. + while (i < json.len and (json[i] == ' ' or json[i] == '\t')) : (i += 1) {} + if (i >= json.len or json[i] != '"') return null; + i += 1; + const value_start = i; + while (i < json.len) : (i += 1) { + if (json[i] == '"' and json[i - 1] != '\\') return json[value_start..i]; + } + return null; +} + +/// Append a `key\tvalue` record (newline-terminated) to a directive builder. +/// This is the canonical way the component hosts assemble what they pass to the +/// generated Java; exposing it keeps the wire format in exactly one place. +pub fn appendDirective(buf: *std.ArrayListUnmanaged(u8), allocator: std.mem.Allocator, key: []const u8, value: []const u8) !void { + try buf.appendSlice(allocator, key); + try buf.append(allocator, '\t'); + // Tabs/newlines in a value would corrupt the record grid; replace them. + for (value) |ch| { + const safe: u8 = switch (ch) { + '\t', '\n', '\r' => ' ', + else => ch, + }; + try buf.append(allocator, safe); + } + try buf.append(allocator, '\n'); +} + +//============================================================================== +// Tests (host-runnable) +//============================================================================== + +const testing = std.testing; + +fn echoHandler(_: [*:0]const u8, _: ?*anyopaque) callconv(.c) [*:0]const u8 { + return "ok\t1\n"; +} + +test "bind then dispatch invokes the registered handler" { + resetForTest(.service); + try testing.expectEqual(BindResult.ok, bind(.service, "onStartCommand", echoHandler, null)); + try testing.expectEqual(@as(usize, 1), handlerCount(.service)); + const out = dispatch(.service, "onStartCommand", "{\"event\":\"onStartCommand\"}"); + try testing.expect(out != null); + try testing.expectEqualStrings("ok\t1\n", std.mem.span(out.?)); +} + +test "dispatch with no binding returns null (host applies default)" { + resetForTest(.receiver); + try testing.expect(dispatch(.receiver, "android.intent.action.BOOT_COMPLETED", "{}") == null); +} + +test "re-binding the same key overwrites in place (idempotent)" { + resetForTest(.widget); + try testing.expectEqual(BindResult.ok, bind(.widget, "onUpdate", echoHandler, null)); + try testing.expectEqual(BindResult.ok, bind(.widget, "onUpdate", echoHandler, null)); + try testing.expectEqual(@as(usize, 1), handlerCount(.widget)); +} + +test "registries are independent per component" { + resetForTest(.service); + resetForTest(.receiver); + _ = bind(.service, "onCreate", echoHandler, null); + try testing.expectEqual(@as(usize, 1), handlerCount(.service)); + try testing.expectEqual(@as(usize, 0), handlerCount(.receiver)); +} + +test "empty key is rejected" { + resetForTest(.service); + try testing.expectEqual(BindResult.invalid_param, bind(.service, "", echoHandler, null)); +} + +test "extractJsonField reads flat string fields" { + const j = "{\"event\":\"onReceive\",\"action\":\"android.intent.action.BOOT_COMPLETED\"}"; + try testing.expectEqualStrings("onReceive", extractJsonField(j, "event").?); + try testing.expectEqualStrings("android.intent.action.BOOT_COMPLETED", extractJsonField(j, "action").?); + try testing.expect(extractJsonField(j, "missing") == null); +} + +test "appendDirective builds tab/newline records and sanitises control chars" { + var buf = std.ArrayListUnmanaged(u8){}; + defer buf.deinit(testing.allocator); + try appendDirective(&buf, testing.allocator, "title", "Neuro\tPhone"); + try appendDirective(&buf, testing.allocator, "sticky", "1"); + try testing.expectEqualStrings("title\tNeuro Phone\nsticky\t1\n", buf.items); +} diff --git a/src/interface/ffi/src/android_receiver.zig b/src/interface/ffi/src/android_receiver.zig new file mode 100644 index 0000000..2a7ac69 --- /dev/null +++ b/src/interface/ffi/src/android_receiver.zig @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Gossamer — Android BroadcastReceiver host +// +// Native side of the generated `io.gossamer.GossamerReceiver` (a subclass of +// android.content.BroadcastReceiver). A BroadcastReceiver is the transient, +// JVM-owned opposite of the Service: the JVM constructs it, calls onReceive +// exactly once, and discards it. There is no Activity and no webview in scope. +// +// onReceive(action, extrasJson) -> handler keyed by `action` -> directive +// +// Handlers are keyed by the Intent action they care about (e.g. +// "android.intent.action.BOOT_COMPLETED"), so an app binds one handler per +// action and gossamer routes precisely. +// +// Directive verbs understood by the generated Java (one per line): +// startService io.gossamer.GossamerService +// startForegroundService io.gossamer.GossamerService +// updateWidgets 1 (broadcast an AppWidget update) +// log +// +// Linearity note: each onReceive is a single scoped borrow that must complete +// within the call window (Android tears the receiver down on return). The +// formal model is the one-shot RcvLive -> RcvComplete machine in +// Gossamer.ABI.AndroidComponents. + +const std = @import("std"); +const jni = @import("jni.zig"); +const comp = @import("android_components.zig"); + +const c_alloc = std.heap.c_allocator; + +export fn Java_io_gossamer_GossamerReceiver_nativeOnReceive( + env: jni.JNIEnv, + _: jni.jobject, + action_j: jni.jstring, + extras_j: jni.jstring, +) jni.jstring { + if (action_j == null) return null; + const action_chars = jni.getStringUTFChars(env, action_j) orelse return null; + defer jni.releaseStringUTFChars(env, action_j, action_chars); + const action = std.mem.span(action_chars); + + var extras_slice: []const u8 = "{}"; + var extras_chars: ?[*:0]const u8 = null; + if (extras_j != null) { + extras_chars = jni.getStringUTFChars(env, extras_j); + if (extras_chars) |ch| extras_slice = std.mem.span(ch); + } + defer if (extras_chars) |ch| jni.releaseStringUTFChars(env, extras_j, ch); + + const event_json = std.fmt.allocPrintZ( + c_alloc, + "{{\"event\":\"onReceive\",\"action\":\"{s}\",\"extras\":{s}}}", + .{ action, extras_slice }, + ) catch return null; + defer c_alloc.free(event_json); + + const directive = comp.dispatch(.receiver, action, event_json) orelse return null; + const result = jni.newStringUTF(env, directive); + _ = jni.clearPendingException(env); + return result; +} + +/// App-facing registration: bind a handler for a specific Intent action. +export fn gossamer_receiver_bind( + action: [*:0]const u8, + callback: ?comp.ComponentCallback, + user_data: ?*anyopaque, +) comp.BindResult { + const cb = callback orelse return .invalid_param; + return comp.bind(.receiver, std.mem.span(action), cb, user_data); +} + +//============================================================================== +// Tests (host-runnable) +//============================================================================== + +const testing = std.testing; + +fn bootHandler(_: [*:0]const u8, _: ?*anyopaque) callconv(.c) [*:0]const u8 { + return "startForegroundService\tio.gossamer.GossamerService\n"; +} + +test "gossamer_receiver_bind routes by action" { + comp.resetForTest(.receiver); + try testing.expectEqual( + comp.BindResult.ok, + gossamer_receiver_bind("android.intent.action.BOOT_COMPLETED", bootHandler, null), + ); + const hit = comp.dispatch(.receiver, "android.intent.action.BOOT_COMPLETED", "{\"event\":\"onReceive\"}"); + try testing.expect(hit != null); + try testing.expect(std.mem.indexOf(u8, std.mem.span(hit.?), "startForegroundService") != null); + // A different action with no binding falls through to host default. + try testing.expect(comp.dispatch(.receiver, "android.intent.action.SCREEN_ON", "{}") == null); +} diff --git a/src/interface/ffi/src/android_service.zig b/src/interface/ffi/src/android_service.zig new file mode 100644 index 0000000..db4f8b3 --- /dev/null +++ b/src/interface/ffi/src/android_service.zig @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Gossamer — Android foreground Service host +// +// Native side of the generated `io.gossamer.GossamerService` (a subclass of +// android.app.Service). The JVM owns the Service lifecycle; this module turns +// each lifecycle callback into a dispatch to an app-registered handler and +// returns a directive the generated Java applies. +// +// onCreate -> handler "onCreate" -> directive (e.g. channel setup) +// onStartCommand -> handler "onStartCommand" -> directive (foreground notif + sticky) +// onDestroy -> handler "onDestroy" -> (teardown; directive ignored) +// +// The foreground-notification directive understood by the generated Java: +// foreground 1 (start in foreground; 0/absent = background) +// channelId (notification channel; created if absent) +// channelName