ReviewOS

also looking at this

craft-native/craft

feat(android): the reply channel, so async actions become reachable

#152
Merged chris wants to merge feat/android-reply-channel into main
5 files +464 -0
packages/android/templates/CraftBridge.kt.templatemodified+21-0
Changes to packages/android/templates/CraftBridge.kt.template
@@ -167,6 +167,11 @@ class CraftBridge(
167167 }
168168
169169 fun injectBridge() {
170 // Before the script, so a native callback arriving while the page is
171 // still loading has somewhere to go rather than being counted as
172 // dropped.
173 installNativeDeliverer()
174
170175 val script = """
171176 window.craft = {
172177 platform: 'android',
@@ -3898,6 +3903,22 @@ class CraftBridge(
38983903
38993904 // ==================== Helpers ====================
39003905
3906 /**
3907 * Give Zig a way to reach the page.
3908 *
3909 * The lambda is this class's own `runOnUiThread`/`evaluateJavascript`
3910 * pair, which is what `sendEvent` below already does — so Zig and Kotlin
3911 * deliver through exactly one implementation rather than two that have to
3912 * agree about threading.
3913 */
3914 private fun installNativeDeliverer() {
3915 CraftNative.setDeliverer { script ->
3916 activity.runOnUiThread {
3917 webView.evaluateJavascript(script, null)
3918 }
3919 }
3920 }
3921
39013922 private fun sendEvent(event: String, data: Map<String, Any>) {
39023923 val json = JSONObject(data).toString()
39033924 activity.runOnUiThread {
packages/android/templates/CraftNative.kt.templatemodified+33-0
Changes to packages/android/templates/CraftNative.kt.template
@@ -58,6 +58,39 @@ object CraftNative {
5858 false
5959 }
6060
61 /**
62 * How Zig reaches the page.
63 *
64 * Zig cannot make a `Runnable`, and `evaluateJavascript` is main-thread
65 * only — JNI can only produce a `Runnable` by registering natives on a
66 * class that already exists in the APK, which means shipping Kotlin to
67 * avoid writing Kotlin. So the hop stays here: Zig owns what to say, this
68 * owns which thread says it.
69 *
70 * Installed by `CraftBridge` once its WebView exists, and `@Volatile`
71 * because Zig calls `deliver` from whatever thread a device callback
72 * arrives on.
73 */
74 @Volatile
75 private var deliverer: ((String) -> Unit)? = null
76
77 fun setDeliverer(deliver: ((String) -> Unit)?) {
78 deliverer = deliver
79 }
80
81 /**
82 * Run `script` in the page. Called from Zig, on any thread.
83 *
84 * Silent when no deliverer is installed — which is every moment before the
85 * WebView exists, and after it is torn down. A throw here would cross back
86 * into Zig as a pending exception on a thread that has nowhere to report
87 * it, and the caller already counts what it could not deliver.
88 */
89 @JvmStatic
90 fun deliver(script: String) {
91 deliverer?.invoke(script)
92 }
93
6194 private external fun nativeGetDeviceInfo(activity: Activity): String?
6295 private external fun nativeGetMemoryUsage(): String?
6396 private external fun nativeLog(message: String): Boolean
packages/zig/src/android_dispatch.zigmodified+5-0
Changes to packages/zig/src/android_dispatch.zig
@@ -44,6 +44,7 @@ const network = ;
4444const securestore = ;
4545const haptics = ;
4646const notifcancel = ;
47const events = ;
4748
4849const Jni = jni.Jni;
4950
@@ -96,6 +97,10 @@ pub const holder_class = "com/craft/runtime/CraftNative";
9697/// linked.
9798pub export fn JNI_OnLoad(vm_handle: jni.JavaVM, _: ?*anyopaque) callconv(.c) jni.jint {
9899 java_vm = vm_handle;
100 // The reply channel needs the same handle, and needs it before any action
101 // runs: a callback that fires between load and the first native call has
102 // nowhere to deliver otherwise.
103 events.setVm(vm_handle);
99104
100105 switch (bindNatives(vm_handle)) {
101106 .registered => {},
packages/zig/src/android_events.zigadded+398-0
Changes to packages/zig/src/android_events.zig
@@ -0,0 +1,398 @@
1//! Pushing JavaScript to the page from Zig, on Android.
2//!
3//! The counterpart to `ios_events.zig`, and it exists because 39 of
4//! `CraftBridge.kt`'s 103 methods reply through
5//! `runOnUiThread { webView.evaluateJavascript(…) }` and nothing else. None of
6//! them can move to Zig until that path does.
7//!
8//! ## Zig cannot make a Runnable, so Kotlin keeps the threading
9//!
10//! `evaluateJavascript` is main-thread-only, and the hop is
11//! `Activity.runOnUiThread(Runnable)`. A `Runnable` is a Java object
12//! implementing an interface, and JNI can only produce one by registering
13//! natives on a class that already exists in the APK — which means shipping a
14//! Kotlin class to avoid writing Kotlin.
15//!
16//! So the hop stays in Kotlin. `CraftNative.deliver(script)` does the
17//! `runOnUiThread` and the `evaluateJavascript`, and Zig calls it as a static
18//! method. Zig owns what to say; Kotlin owns which thread says it. That split
19//! is also why this file is small: it is a channel, not a scheduler.
20//!
21//! ## The env is per-thread and the VM is not
22//!
23//! A `JNIEnv` belongs to one thread and caching it across threads is the
24//! classic JNI crash. A `JavaVM` is process-wide. So every call here asks the
25//! VM for *this* thread's env, and attaches the thread when it has none —
26//! which is the normal case for a callback arriving on a framework thread,
27//! exactly like a `CLLocationManager` delegate on iOS.
28//!
29//! A thread this file attaches, this file detaches. Leaving one attached keeps
30//! the JVM's thread record alive after the OS thread is gone, and the crash
31//! surfaces somewhere unrelated much later.
32
33const std = ;
34const jni = ;
35
36/// The class holding `deliver`. Fixed, for the reason `android_dispatch.zig`
37/// gives: the app's own package is templated and a prebuilt library cannot
38/// name it.
39pub const holder_class = "com/craft/runtime/CraftNative";
40
41/// The process-wide VM, captured at `JNI_OnLoad`.
42var java_vm: ?jni.JavaVM = null;
43
44pub fn setVm(vm: jni.JavaVM) void {
45 java_vm = vm;
46}
47
48pub fn clearVm() void {
49 java_vm = null;
50}
51
52/// How many scripts were dropped, and why the count exists.
53///
54/// A dropped script is a page that never hears back, and the only symptom is
55/// a promise that does not settle. Counted rather than logged per-drop: the
56/// paths that drop are the ones a device takes in bulk — no VM before load, no
57/// WebView before the page exists — and a log line each would drown the thing
58/// being diagnosed.
59var dropped: usize = 0;
60
61pub fn droppedCount() usize {
62 return dropped;
63}
64
65pub fn resetDroppedForTest() void {
66 dropped = 0;
67}
68
69/// This thread's env, attaching if it has none.
70///
71/// Returns whether the caller must detach. A thread the JVM created — the UI
72/// thread, a binder thread — is already attached and must **not** be detached
73/// by us; a thread Zig or a native library created must be.
74const Attachment = struct {
75 env: jni.JNIEnv,
76 owned: bool,
77};
78
79fn attach(vm: jni.JavaVM) ?Attachment {
80 if (jni.envForThisThread(vm)) |env| return .{ .env = env, .owned = false };
81
82 const attach_fn: *const fn (jni.JavaVM, *?*anyopaque, ?*anyopaque) callconv(.c) jni.jint =
83 @ptrCast(vm.*.AttachCurrentThread orelse return null);
84
85 var env_ptr: ?*anyopaque = null;
86 if (attach_fn(vm, &env_ptr, null) != jni.JNI_OK) return null;
87 const env: jni.JNIEnv = @ptrCast(@alignCast(env_ptr orelse return null));
88 return .{ .env = env, .owned = true };
89}
90
91fn detach(vm: jni.JavaVM) void {
92 const detach_fn: *const fn (jni.JavaVM) callconv(.c) jni.jint =
93 @ptrCast(vm.*.DetachCurrentThread orelse return);
94 _ = detach_fn(vm);
95}
96
97/// Hand `script` to `CraftNative.deliver`, from any thread.
98///
99/// Silent on every failure, and deliberately: the caller is a device callback
100/// with nowhere to report to, and the alternatives are a log per location fix
101/// or an error nobody reads. `droppedCount` is what a diagnosis reads instead.
102pub fn evaluate(script: [*:0]const u8) void {
103 const vm = java_vm orelse {
104 dropped += 1;
105 return;
106 };
107
108 const attachment = attach(vm) orelse {
109 dropped += 1;
110 return;
111 };
112 defer if (attachment.owned) detach(vm);
113
114 deliverThrough(attachment.env, script) catch {
115 dropped += 1;
116 };
117}
118
119fn deliverThrough(env: jni.JNIEnv, script: [*:0]const u8) !void {
120 const j = jni.Jni.init(env);
121
122 // A frame around the class, the string and any pending throwable. Without
123 // it a stream delivering several times a second leaks a local ref per
124 // delivery and overflows the JVM's sixteen slots — which aborts the
125 // process rather than failing a call.
126 try j.pushLocalFrame(8);
127 defer _ = j.popLocalFrame(null);
128
129 const cls = try j.findClass(holder_class);
130 const text = try j.newStringUtf(script);
131
132 const deliver = try j.staticMethodId(cls, "deliver", "(Ljava/lang/String;)V");
133 try j.callStaticVoidMethodA(cls, deliver, &.{.{ .l = text }});
134}
135
136/// `window.dispatchEvent(new CustomEvent('name', {detail: json}))`.
137///
138/// `detail_json` is inlined as an object literal rather than escaped into a
139/// string, so a value containing a quote cannot break out — the same reasoning
140/// `ios_events.zig` records. The *name* is never interpolated from anything a
141/// page or a device can influence; callers pass a literal.
142pub fn emitEvent(
143 allocator: std.mem.Allocator,
144 name: []const u8,
145 detail_json: []const u8,
146) !void {
147 var out: std.ArrayListUnmanaged(u8) = .empty;
148 defer out.deinit(allocator);
149
150 try out.appendSlice(allocator, "window.dispatchEvent(new CustomEvent('");
151 try out.appendSlice(allocator, name);
152 try out.appendSlice(allocator, "', {detail: ");
153 try out.appendSlice(allocator, detail_json);
154 try out.appendSlice(allocator, "}));");
155 try out.append(allocator, 0);
156
157 evaluate(@ptrCast(out.items.ptr));
158}
159
160/// `window._craftXResolve && window._craftXResolve(payload)`.
161///
162/// The guard is not defensive noise. These globals are assigned by the
163/// injected JS *inside* a promise executor, so between an action being called
164/// and its promise being constructed the global does not exist — and a device
165/// callback that arrives in that window would otherwise throw a ReferenceError
166/// inside `evaluateJavascript`, where nothing sees it. The Kotlin writes the
167/// same guard for the same reason.
168pub fn settle(
169 allocator: std.mem.Allocator,
170 global: []const u8,
171 payload_json: []const u8,
172) !void {
173 var out: std.ArrayListUnmanaged(u8) = .empty;
174 defer out.deinit(allocator);
175
176 try out.appendSlice(allocator, "window.");
177 try out.appendSlice(allocator, global);
178 try out.appendSlice(allocator, " && window.");
179 try out.appendSlice(allocator, global);
180 try out.append(allocator, '(');
181 try out.appendSlice(allocator, payload_json);
182 try out.appendSlice(allocator, ");");
183 try out.append(allocator, 0);
184
185 evaluate(@ptrCast(out.items.ptr));
186}
187
188// =============================================================================
189// Tests
190//
191// The whole channel is testable because both tables are just function
192// pointers: the JavaVM's GetEnv and AttachCurrentThread as much as the env's
193// FindClass. So a test can be a thread that is attached, a thread that is not,
194// or a process with no VM at all.
195// =============================================================================
196
197const testing = std.testing;
198
199var fake_env_table: jni.JNINativeInterface = undefined;
200var fake_env_ptr: *const jni.JNINativeInterface = undefined;
201var fake_storage: [8]u8 = undefined;
202
203var fake_delivered: [512]u8 = undefined;
204var fake_delivered_len: usize = 0;
205var fake_attach_calls: usize = 0;
206var fake_detach_calls: usize = 0;
207var fake_already_attached = true;
208var fake_attach_succeeds = true;
209var fake_find_class_succeeds = true;
210
211fn eobj(tag: usize) jni.jobject {
212 return @ptrCast(&fake_storage[tag]);
213}
214
215fn eGetEnv(_: jni.JavaVM, out: *?*anyopaque, _: jni.jint) callconv(.c) jni.jint {
216 if (!fake_already_attached) return jni.JNI_EDETACHED;
217 out.* = @ptrCast(@constCast(&fake_env_ptr));
218 return jni.JNI_OK;
219}
220fn eAttach(_: jni.JavaVM, out: *?*anyopaque, _: ?*anyopaque) callconv(.c) jni.jint {
221 fake_attach_calls += 1;
222 if (!fake_attach_succeeds) return -1;
223 out.* = @ptrCast(@constCast(&fake_env_ptr));
224 return jni.JNI_OK;
225}
226fn eDetach(_: jni.JavaVM) callconv(.c) jni.jint {
227 fake_detach_calls += 1;
228 return jni.JNI_OK;
229}
230
231fn eFindClass(_: jni.JNIEnv, _: [*:0]const u8) callconv(.c) jni.jclass {
232 return if (fake_find_class_succeeds) eobj(0) else null;
233}
234fn eStaticMethodId(_: jni.JNIEnv, _: jni.jclass, _: [*:0]const u8, _: [*:0]const u8) callconv(.c) jni.jmethodID {
235 return eobj(1);
236}
237fn eNewStringUTF(_: jni.JNIEnv, text: [*:0]const u8) callconv(.c) jni.jstring {
238 const span = std.mem.span(text);
239 fake_delivered_len = @min(span.len, fake_delivered.len);
240 @memcpy(fake_delivered[0..fake_delivered_len], span[0..fake_delivered_len]);
241 return eobj(2);
242}
243fn eCallStaticVoidMethodA(_: jni.JNIEnv, _: jni.jclass, _: jni.jmethodID, _: [*]const jni.jvalue) callconv(.c) void {}
244fn eExceptionOccurred(_: jni.JNIEnv) callconv(.c) jni.jobject {
245 return null;
246}
247fn ePush(_: jni.JNIEnv, _: jni.jint) callconv(.c) jni.jint {
248 return 0;
249}
250fn ePop(_: jni.JNIEnv, keep: jni.jobject) callconv(.c) jni.jobject {
251 return keep;
252}
253
254fn fakeVm(invoke: *jni.JNIInvokeInterface) void {
255 fake_env_table = std.mem.zeroes(jni.JNINativeInterface);
256 fake_env_table.FindClass = @ptrCast(&eFindClass);
257 fake_env_table.GetStaticMethodID = @ptrCast(&eStaticMethodId);
258 fake_env_table.NewStringUTF = @ptrCast(&eNewStringUTF);
259 fake_env_table.CallStaticVoidMethodA = @ptrCast(&eCallStaticVoidMethodA);
260 fake_env_table.ExceptionOccurred = @ptrCast(&eExceptionOccurred);
261 fake_env_table.PushLocalFrame = @ptrCast(&ePush);
262 fake_env_table.PopLocalFrame = @ptrCast(&ePop);
263 fake_env_ptr = &fake_env_table;
264
265 invoke.* = std.mem.zeroes(jni.JNIInvokeInterface);
266 invoke.GetEnv = @ptrCast(&eGetEnv);
267 invoke.AttachCurrentThread = @ptrCast(&eAttach);
268 invoke.DetachCurrentThread = @ptrCast(&eDetach);
269
270 fake_delivered_len = 0;
271 fake_attach_calls = 0;
272 fake_detach_calls = 0;
273 fake_already_attached = true;
274 fake_attach_succeeds = true;
275 fake_find_class_succeeds = true;
276 resetDroppedForTest();
277}
278
279fn delivered() []const u8 {
280 return fake_delivered[0..fake_delivered_len];
281}
282
283test "a script reaches deliver on an already-attached thread, and nothing is detached" {
284 var invoke: jni.JNIInvokeInterface = undefined;
285 fakeVm(&invoke);
286 const ptr: *const jni.JNIInvokeInterface = &invoke;
287 setVm(&ptr);
288 defer clearVm();
289
290 evaluate("window.x = 1;");
291 try testing.expectEqualStrings("window.x = 1;", delivered());
292
293 // The UI thread and every binder thread are already attached, and
294 // detaching one the JVM created tears down a thread the JVM still uses.
295 try testing.expectEqual(@as(usize, 0), fake_attach_calls);
296 try testing.expectEqual(@as(usize, 0), fake_detach_calls);
297 try testing.expectEqual(@as(usize, 0), droppedCount());
298}
299
300test "a detached thread is attached and then detached again" {
301 // The case that matters: a device callback on a thread the JVM did not
302 // create. Leaving it attached keeps the JVM's thread record alive after
303 // the OS thread is gone, and the crash surfaces somewhere unrelated later.
304 var invoke: jni.JNIInvokeInterface = undefined;
305 fakeVm(&invoke);
306 const ptr: *const jni.JNIInvokeInterface = &invoke;
307 setVm(&ptr);
308 defer clearVm();
309
310 fake_already_attached = false;
311 evaluate("window.y = 2;");
312
313 try testing.expectEqualStrings("window.y = 2;", delivered());
314 try testing.expectEqual(@as(usize, 1), fake_attach_calls);
315 try testing.expectEqual(@as(usize, 1), fake_detach_calls);
316}
317
318test "every way delivery can fail is counted, not logged and not crashed" {
319 var invoke: jni.JNIInvokeInterface = undefined;
320 fakeVm(&invoke);
321 const ptr: *const jni.JNIInvokeInterface = &invoke;
322
323 // No VM at all — before JNI_OnLoad, or in a build with no runtime linked.
324 clearVm();
325 resetDroppedForTest();
326 evaluate("window.z = 3;");
327 try testing.expectEqual(@as(usize, 1), droppedCount());
328
329 // A thread that cannot be attached.
330 setVm(&ptr);
331 defer clearVm();
332 fake_already_attached = false;
333 fake_attach_succeeds = false;
334 resetDroppedForTest();
335 evaluate("window.z = 3;");
336 try testing.expectEqual(@as(usize, 1), droppedCount());
337 // Nothing was attached, so nothing may be detached.
338 try testing.expectEqual(@as(usize, 0), fake_detach_calls);
339
340 // The holder class missing — a runtime loaded against a stale APK.
341 fake_already_attached = true;
342 fake_attach_succeeds = true;
343 fake_find_class_succeeds = false;
344 resetDroppedForTest();
345 evaluate("window.z = 3;");
346 try testing.expectEqual(@as(usize, 1), droppedCount());
347}
348
349test "an attached thread is detached even when delivery fails" {
350 // The leak this pins: a `defer` that sat after the delivery call, or
351 // inside an `if`, would skip the detach on exactly the path that already
352 // went wrong.
353 var invoke: jni.JNIInvokeInterface = undefined;
354 fakeVm(&invoke);
355 const ptr: *const jni.JNIInvokeInterface = &invoke;
356 setVm(&ptr);
357 defer clearVm();
358
359 fake_already_attached = false;
360 fake_find_class_succeeds = false;
361 evaluate("window.z = 3;");
362
363 try testing.expectEqual(@as(usize, 1), fake_attach_calls);
364 try testing.expectEqual(@as(usize, 1), fake_detach_calls);
365 try testing.expectEqual(@as(usize, 1), droppedCount());
366}
367
368test "an event inlines its detail rather than escaping it into a string" {
369 var invoke: jni.JNIInvokeInterface = undefined;
370 fakeVm(&invoke);
371 const ptr: *const jni.JNIInvokeInterface = &invoke;
372 setVm(&ptr);
373 defer clearVm();
374
375 try emitEvent(testing.allocator, "craftMotionUpdate", "{\"x\":1,\"y\":\"a\\\"b\"}");
376 try testing.expectEqualStrings(
377 "window.dispatchEvent(new CustomEvent('craftMotionUpdate', {detail: {\"x\":1,\"y\":\"a\\\"b\"}}));",
378 delivered(),
379 );
380}
381
382test "settling guards the global that the promise may not have assigned yet" {
383 // These globals are assigned inside a promise executor, so between the
384 // action being called and the promise being built the global does not
385 // exist — and a callback arriving in that window would throw a
386 // ReferenceError inside evaluateJavascript, where nothing sees it.
387 var invoke: jni.JNIInvokeInterface = undefined;
388 fakeVm(&invoke);
389 const ptr: *const jni.JNIInvokeInterface = &invoke;
390 setVm(&ptr);
391 defer clearVm();
392
393 try settle(testing.allocator, "_craftLocationResolve", "{\"latitude\":1}");
394 try testing.expectEqualStrings(
395 "window._craftLocationResolve && window._craftLocationResolve({\"latitude\":1});",
396 delivered(),
397 );
398}
packages/zig/src/jni_runtime.zigmodified+7-0
Changes to packages/zig/src/jni_runtime.zig
@@ -775,6 +775,13 @@ pub const Jni = struct {
775775 return result;
776776 }
777777
778 pub fn callStaticVoidMethodA(self: Self, cls: jclass, id: jmethodID, args: []const jvalue) JniError!void {
779 const call: *const fn (JNIEnv, jclass, jmethodID, [*]const jvalue) callconv(.c) void =
780 @ptrCast(self.table().CallStaticVoidMethodA orelse return JniError.NotFound);
781 call(self.env, cls, id, args.ptr);
782 try self.check();
783 }
784
778785 pub fn callStaticIntMethodA(self: Self, cls: jclass, id: jmethodID, args: []const jvalue) JniError!jint {
779786 const call: *const fn (JNIEnv, jclass, jmethodID, [*]const jvalue) callconv(.c) jint =
780787 @ptrCast(self.table().CallStaticIntMethodA orelse return JniError.NotFound);