ReviewOS

also looking at this

craft-native/craft

fix(android): text going to the JVM is modified UTF-8, and was not being

#168
Merged chris wants to merge fix/android-modified-utf8 into main
12 files +326 -136
packages/zig/src/android_events.zigmodified+60-14
Changes to packages/zig/src/android_events.zig
@@ -99,7 +99,7 @@ fn detach(vm: jni.JavaVM) void {
9999/// Silent on every failure, and deliberately: the caller is a device callback
100100/// with nowhere to report to, and the alternatives are a log per location fix
101101/// or an error nobody reads. `droppedCount` is what a diagnosis reads instead.
102pub fn evaluate(script: [*:0]const u8) void {
102pub fn evaluate(allocator: std.mem.Allocator, script: []const u8) void {
103103 const vm = java_vm orelse {
104104 dropped += 1;
105105 return;
@@ -111,12 +111,12 @@ pub fn evaluate(script: [*:0]const u8) void {
111111 };
112112 defer if (attachment.owned) detach(vm);
113113
114 deliverThrough(attachment.env, script) catch {
114 deliverThrough(allocator, attachment.env, script) catch {
115115 dropped += 1;
116116 };
117117}
118118
119fn deliverThrough(env: jni.JNIEnv, script: [*:0]const u8) !void {
119fn deliverThrough(allocator: std.mem.Allocator, env: jni.JNIEnv, script: []const u8) !void {
120120 const j = jni.Jni.init(env);
121121
122122 // A frame around the class, the string and any pending throwable. Without
@@ -127,7 +127,11 @@ fn deliverThrough(env: jni.JNIEnv, script: [*:0]const u8) !void {
127127 defer _ = j.popLocalFrame(null);
128128
129129 const cls = try j.findClass(holder_class);
130 const text = try j.newStringUtf(script);
130 // Re-encoded rather than handed over: a reply carries page and device
131 // text — a contact's name, an event's title, an error message — and
132 // `NewStringUTF` reads modified UTF-8, in which an emoji is two three-byte
133 // halves rather than one four-byte sequence.
134 const text = try j.newStringUtf8(allocator, script);
131135
132136 const deliver = try j.staticMethodId(cls, "deliver", "(Ljava/lang/String;)V");
133137 try j.callStaticVoidMethodA(cls, deliver, &.{.{ .l = text }});
@@ -152,9 +156,8 @@ pub fn emitEvent(
152156 try out.appendSlice(allocator, "', {detail: ");
153157 try out.appendSlice(allocator, detail_json);
154158 try out.appendSlice(allocator, "}));");
155 try out.append(allocator, 0);
156159
157 evaluate(@ptrCast(out.items.ptr));
160 evaluate(allocator, out.items);
158161}
159162
160163/// `window._craftXResolve && window._craftXResolve(payload)`.
@@ -180,9 +183,8 @@ pub fn settle(
180183 try out.append(allocator, '(');
181184 try out.appendSlice(allocator, payload_json);
182185 try out.appendSlice(allocator, ");");
183 try out.append(allocator, 0);
184186
185 evaluate(@ptrCast(out.items.ptr));
187 evaluate(allocator, out.items);
186188}
187189
188190// =============================================================================
@@ -287,7 +289,7 @@ test "a script reaches deliver on an already-attached thread, and nothing is det
287289 setVm(&ptr);
288290 defer clearVm();
289291
290 evaluate("window.x = 1;");
292 evaluate(testing.allocator, "window.x = 1;");
291293 try testing.expectEqualStrings("window.x = 1;", delivered());
292294
293295 // The UI thread and every binder thread are already attached, and
@@ -308,7 +310,7 @@ test "a detached thread is attached and then detached again" {
308310 defer clearVm();
309311
310312 fake_already_attached = false;
311 evaluate("window.y = 2;");
313 evaluate(testing.allocator, "window.y = 2;");
312314
313315 try testing.expectEqualStrings("window.y = 2;", delivered());
314316 try testing.expectEqual(@as(usize, 1), fake_attach_calls);
@@ -323,7 +325,7 @@ test "every way delivery can fail is counted, not logged and not crashed" {
323325 // No VM at all — before JNI_OnLoad, or in a build with no runtime linked.
324326 clearVm();
325327 resetDroppedForTest();
326 evaluate("window.z = 3;");
328 evaluate(testing.allocator, "window.z = 3;");
327329 try testing.expectEqual(@as(usize, 1), droppedCount());
328330
329331 // A thread that cannot be attached.
@@ -332,7 +334,7 @@ test "every way delivery can fail is counted, not logged and not crashed" {
332334 fake_already_attached = false;
333335 fake_attach_succeeds = false;
334336 resetDroppedForTest();
335 evaluate("window.z = 3;");
337 evaluate(testing.allocator, "window.z = 3;");
336338 try testing.expectEqual(@as(usize, 1), droppedCount());
337339 // Nothing was attached, so nothing may be detached.
338340 try testing.expectEqual(@as(usize, 0), fake_detach_calls);
@@ -342,7 +344,7 @@ test "every way delivery can fail is counted, not logged and not crashed" {
342344 fake_attach_succeeds = true;
343345 fake_find_class_succeeds = false;
344346 resetDroppedForTest();
345 evaluate("window.z = 3;");
347 evaluate(testing.allocator, "window.z = 3;");
346348 try testing.expectEqual(@as(usize, 1), droppedCount());
347349}
348350
@@ -358,7 +360,7 @@ test "an attached thread is detached even when delivery fails" {
358360
359361 fake_already_attached = false;
360362 fake_find_class_succeeds = false;
361 evaluate("window.z = 3;");
363 evaluate(testing.allocator, "window.z = 3;");
362364
363365 try testing.expectEqual(@as(usize, 1), fake_attach_calls);
364366 try testing.expectEqual(@as(usize, 1), fake_detach_calls);
@@ -396,3 +398,47 @@ test "settling guards the global that the promise may not have assigned yet" {
396398 delivered(),
397399 );
398400}
401
402test "a reply carrying an emoji reaches the JVM as a surrogate pair" {
403 // The end of the wire this whole change is about. A contact called a
404 // smiley arrives here as four UTF-8 bytes, and `NewStringUTF` reads
405 // *modified* UTF-8, where the same character is two three-byte halves.
406 // Handing over the standard form does not produce the string that was
407 // meant.
408 var invoke: jni.JNIInvokeInterface = undefined;
409 fakeVm(&invoke);
410 const ptr: *const jni.JNIInvokeInterface = &invoke;
411 setVm(&ptr);
412 defer clearVm();
413
414 try settle(testing.allocator, "_craftContactsResolve", "[{\"displayName\":\"\u{1F642}\"}]");
415
416 try testing.expectEqualStrings(
417 "window._craftContactsResolve && window._craftContactsResolve([{\"displayName\":\"" ++
418 "\xED\xA0\xBD\xED\xB9\x82" ++ "\"}]);",
419 delivered(),
420 );
421
422 // And the four-byte form is gone rather than merely accompanied.
423 try testing.expect(std.mem.indexOf(u8, delivered(), "\xF0\x9F\x99\x82") == null);
424}
425
426test "a NUL in a reply does not truncate it" {
427 // `NewStringUTF` reads to a terminator, so a payload carrying U+0000 would
428 // end the script early and `evaluateJavascript` would run the fragment
429 // before it. A database column holding a NUL byte is the everyday way to
430 // get one.
431 var invoke: jni.JNIInvokeInterface = undefined;
432 fakeVm(&invoke);
433 const ptr: *const jni.JNIInvokeInterface = &invoke;
434 setVm(&ptr);
435 defer clearVm();
436
437 try settle(testing.allocator, "_craftDbQueryResolve", "[\"a\x00b\"]");
438
439 try testing.expectEqualStrings(
440 "window._craftDbQueryResolve && window._craftDbQueryResolve([\"a" ++
441 "\xC0\x80" ++ "b\"]);",
442 delivered(),
443 );
444}