| @@ -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 | + |
| 33 | +const std = ; |
| 34 | +const 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. |
| 39 | +pub const holder_class = "com/craft/runtime/CraftNative"; |
| 40 | + |
| 41 | +/// The process-wide VM, captured at `JNI_OnLoad`. |
| 42 | +var java_vm: ?jni.JavaVM = null; |
| 43 | + |
| 44 | +pub fn setVm(vm: jni.JavaVM) void { |
| 45 | + java_vm = vm; |
| 46 | +} |
| 47 | + |
| 48 | +pub 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. |
| 59 | +var dropped: usize = 0; |
| 60 | + |
| 61 | +pub fn droppedCount() usize { |
| 62 | + return dropped; |
| 63 | +} |
| 64 | + |
| 65 | +pub 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. |
| 74 | +const Attachment = struct { |
| 75 | + env: jni.JNIEnv, |
| 76 | + owned: bool, |
| 77 | +}; |
| 78 | + |
| 79 | +fn 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 | + |
| 91 | +fn 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. |
| 102 | +pub 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 | + |
| 119 | +fn 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. |
| 142 | +pub 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. |
| 168 | +pub 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 | + |
| 197 | +const testing = std.testing; |
| 198 | + |
| 199 | +var fake_env_table: jni.JNINativeInterface = undefined; |
| 200 | +var fake_env_ptr: *const jni.JNINativeInterface = undefined; |
| 201 | +var fake_storage: [8]u8 = undefined; |
| 202 | + |
| 203 | +var fake_delivered: [512]u8 = undefined; |
| 204 | +var fake_delivered_len: usize = 0; |
| 205 | +var fake_attach_calls: usize = 0; |
| 206 | +var fake_detach_calls: usize = 0; |
| 207 | +var fake_already_attached = true; |
| 208 | +var fake_attach_succeeds = true; |
| 209 | +var fake_find_class_succeeds = true; |
| 210 | + |
| 211 | +fn eobj(tag: usize) jni.jobject { |
| 212 | + return @ptrCast(&fake_storage[tag]); |
| 213 | +} |
| 214 | + |
| 215 | +fn 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 | +} |
| 220 | +fn 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 | +} |
| 226 | +fn eDetach(_: jni.JavaVM) callconv(.c) jni.jint { |
| 227 | + fake_detach_calls += 1; |
| 228 | + return jni.JNI_OK; |
| 229 | +} |
| 230 | + |
| 231 | +fn eFindClass(_: jni.JNIEnv, _: [*:0]const u8) callconv(.c) jni.jclass { |
| 232 | + return if (fake_find_class_succeeds) eobj(0) else null; |
| 233 | +} |
| 234 | +fn eStaticMethodId(_: jni.JNIEnv, _: jni.jclass, _: [*:0]const u8, _: [*:0]const u8) callconv(.c) jni.jmethodID { |
| 235 | + return eobj(1); |
| 236 | +} |
| 237 | +fn 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 | +} |
| 243 | +fn eCallStaticVoidMethodA(_: jni.JNIEnv, _: jni.jclass, _: jni.jmethodID, _: [*]const jni.jvalue) callconv(.c) void {} |
| 244 | +fn eExceptionOccurred(_: jni.JNIEnv) callconv(.c) jni.jobject { |
| 245 | + return null; |
| 246 | +} |
| 247 | +fn ePush(_: jni.JNIEnv, _: jni.jint) callconv(.c) jni.jint { |
| 248 | + return 0; |
| 249 | +} |
| 250 | +fn ePop(_: jni.JNIEnv, keep: jni.jobject) callconv(.c) jni.jobject { |
| 251 | + return keep; |
| 252 | +} |
| 253 | + |
| 254 | +fn 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 | + |
| 279 | +fn delivered() []const u8 { |
| 280 | + return fake_delivered[0..fake_delivered_len]; |
| 281 | +} |
| 282 | + |
| 283 | +test "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 | + |
| 300 | +test "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 | + |
| 318 | +test "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 | + |
| 349 | +test "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 | + |
| 368 | +test "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 | + |
| 382 | +test "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 | +} |