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_dispatch.zigmodified+26-41
Changes to packages/zig/src/android_dispatch.zig
@@ -359,14 +359,10 @@ fn nativeGetDeviceInfo(env: jni.JNIEnv, _: jni.jobject, activity: jni.jobject) c
359359
360360 const json = device.render(allocator, info) catch return null;
361361
362 // `NewStringUTF` reads to a NUL, and `render` produces a plain slice.
363 // Copied rather than terminated in place because the reply is JSON built
364 // for the page, and giving `render` a sentinel to satisfy would put a
365 // JNI detail into the half of this action that has nothing to do with Java.
366 const owned = allocator.allocSentinel(u8, json.len, 0) catch return null;
367 @memcpy(owned, json);
368
369 return j.newStringUtf(owned.ptr) catch null;
362 // Re-encoded rather than handed over: this JSON carries device text — a
363 // model name, a carrier — and `NewStringUTF` reads modified UTF-8, where a
364 // NUL is two bytes and an astral character is a surrogate pair.
365 return j.newStringUtf8(allocator, json) catch null;
370366}
371367
372368/// `nativeGetMemoryUsage()` — the JVM heap, as JSON.
@@ -383,10 +379,7 @@ fn nativeGetMemoryUsage(env: jni.JNIEnv, _: jni.jobject) callconv(.c) jni.jstrin
383379 };
384380
385381 const json = system.renderMemory(allocator, usage) catch return null;
386 const owned = allocator.allocSentinel(u8, json.len, 0) catch return null;
387 @memcpy(owned, json);
388
389 return j.newStringUtf(owned.ptr) catch null;
382 return j.newStringUtf8(allocator, json) catch null;
390383}
391384
392385/// `nativeLog(message)` — returns whether Zig wrote the line.
@@ -402,10 +395,8 @@ fn nativeLog(env: jni.JNIEnv, _: jni.jobject, message: jni.jstring) callconv(.c)
402395 const allocator = arena.allocator();
403396
404397 const text = j.stringToUtf8(allocator, message) catch return jni.JNI_FALSE;
405 const owned = allocator.allocSentinel(u8, text.len, 0) catch return jni.JNI_FALSE;
406 @memcpy(owned, text);
407398
408 system.writeLog(j, owned.ptr) catch |err| {
399 system.writeLog(j, allocator, text) catch |err| {
409400 std.log.warn("craft: log fell through to the shim ({s})", .{@errorName(err)});
410401 return jni.JNI_FALSE;
411402 };
@@ -429,9 +420,7 @@ fn nativeClipboardRead(env: jni.JNIEnv, _: jni.jobject, activity: jni.jobject) c
429420 return null;
430421 };
431422
432 const owned = allocator.allocSentinel(u8, text.len, 0) catch return null;
433 @memcpy(owned, text);
434 return j.newStringUtf(owned.ptr) catch null;
423 return j.newStringUtf8(allocator, text) catch null;
435424}
436425
437426/// `nativeClipboardWrite(activity, text)`.
@@ -448,25 +437,21 @@ fn nativeClipboardWrite(
448437 const allocator = arena.allocator();
449438
450439 const value = j.stringToUtf8(allocator, text) catch return jni.JNI_FALSE;
451 const owned = allocator.allocSentinel(u8, value.len, 0) catch return jni.JNI_FALSE;
452 @memcpy(owned, value);
453440
454 clipboard.write(j, activity, owned.ptr) catch |err| {
441 clipboard.write(j, allocator, activity, value) catch |err| {
455442 std.log.warn("craft: clipboardWrite fell through to the shim ({s})", .{@errorName(err)});
456443 return jni.JNI_FALSE;
457444 };
458445 return jni.JNI_TRUE;
459446}
460447
461/// Copy a `jstring` into NUL-terminated arena memory, or null.
448/// A `jstring` as arena-owned UTF-8, or null.
462449///
463450/// Every native below needs this and none of them can share the result, since
464/// each arena dies with its call.
465fn ownedUtf8(j: Jni, allocator: std.mem.Allocator, str: jni.jstring) ?[:0]u8 {
466 const text = j.stringToUtf8(allocator, str) catch return null;
467 const owned = allocator.allocSentinel(u8, text.len, 0) catch return null;
468 @memcpy(owned, text);
469 return owned;
451/// each arena dies with its call. A plain slice: the JNI side re-encodes on
452/// the way back out, so nothing here has to carry a terminator.
453fn ownedUtf8(j: Jni, allocator: std.mem.Allocator, str: jni.jstring) ?[]u8 {
454 return j.stringToUtf8(allocator, str) catch null;
470455}
471456
472457fn nativeOpenUrl(
@@ -479,8 +464,10 @@ fn nativeOpenUrl(
479464 var arena = std.heap.ArenaAllocator.init(backing);
480465 defer arena.deinit();
481466
482 const value = ownedUtf8(j, arena.allocator(), url) orelse return jni.JNI_FALSE;
483 intents.openUrl(j, activity, value.ptr) catch |err| {
467 const allocator = arena.allocator();
468
469 const value = ownedUtf8(j, allocator, url) orelse return jni.JNI_FALSE;
470 intents.openUrl(j, allocator, activity, value) catch |err| {
484471 // Not a warning. `openURL` answering false is the documented outcome
485472 // when nothing on the device handles the scheme, and the Kotlin
486473 // catches exactly this — logging it at warn would put a line in
@@ -506,7 +493,7 @@ fn nativeShare(
506493 const body = ownedUtf8(j, allocator, text) orelse return jni.JNI_FALSE;
507494 const subject = ownedUtf8(j, allocator, title) orelse return jni.JNI_FALSE;
508495
509 intents.share(j, activity, body.ptr, subject.ptr) catch |err| {
496 intents.share(j, allocator, activity, body, subject) catch |err| {
510497 std.log.warn("craft: share fell through to the shim ({s})", .{@errorName(err)});
511498 return jni.JNI_FALSE;
512499 };
@@ -525,9 +512,7 @@ fn nativeGetNetworkStatus(env: jni.JNIEnv, _: jni.jobject, activity: jni.jobject
525512 };
526513
527514 const json = network.render(allocator, status) catch return null;
528 const owned = allocator.allocSentinel(u8, json.len, 0) catch return null;
529 @memcpy(owned, json);
530 return j.newStringUtf(owned.ptr) catch null;
515 return j.newStringUtf8(allocator, json) catch null;
531516}
532517
533518fn nativeSecureSet(
@@ -545,7 +530,7 @@ fn nativeSecureSet(
545530 const k = ownedUtf8(j, allocator, key) orelse return jni.JNI_FALSE;
546531 const v = ownedUtf8(j, allocator, value) orelse return jni.JNI_FALSE;
547532
548 securestore.set(j, prefs, k.ptr, v.ptr) catch |err| {
533 securestore.set(j, allocator, prefs, k, v) catch |err| {
549534 std.log.warn("craft: secureSet fell through to the shim ({s})", .{@errorName(err)});
550535 return jni.JNI_FALSE;
551536 };
@@ -569,15 +554,13 @@ fn nativeSecureGet(
569554 const allocator = arena.allocator();
570555
571556 const k = ownedUtf8(j, allocator, key) orelse return null;
572 const value = securestore.get(allocator, j, prefs, k.ptr) catch |err| {
557 const value = securestore.get(allocator, j, prefs, k) catch |err| {
573558 std.log.warn("craft: secureGet fell through to the shim ({s})", .{@errorName(err)});
574559 return null;
575560 };
576561
577562 const json = securestore.renderRead(allocator, value) catch return null;
578 const owned = allocator.allocSentinel(u8, json.len, 0) catch return null;
579 @memcpy(owned, json);
580 return j.newStringUtf(owned.ptr) catch null;
563 return j.newStringUtf8(allocator, json) catch null;
581564}
582565
583566fn nativeSecureRemove(
@@ -590,8 +573,10 @@ fn nativeSecureRemove(
590573 var arena = std.heap.ArenaAllocator.init(backing);
591574 defer arena.deinit();
592575
593 const k = ownedUtf8(j, arena.allocator(), key) orelse return jni.JNI_FALSE;
594 securestore.remove(j, prefs, k.ptr) catch |err| {
576 const allocator = arena.allocator();
577
578 const k = ownedUtf8(j, allocator, key) orelse return jni.JNI_FALSE;
579 securestore.remove(j, allocator, prefs, k) catch |err| {
595580 std.log.warn("craft: secureRemove fell through to the shim ({s})", .{@errorName(err)});
596581 return jni.JNI_FALSE;
597582 };
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}
packages/zig/src/bridge_android_calendar.zigmodified+4-7
Changes to packages/zig/src/bridge_android_calendar.zig
@@ -612,15 +612,12 @@ fn putString(
612612 try j.pushLocalFrame(4);
613613 defer _ = j.popLocalFrame(null);
614614
615 // `NewStringUTF` needs a NUL terminator, and a title is arbitrary page
616 // text — so it is copied rather than pointed at.
617 const terminated = try allocator.allocSentinel(u8, text.len, 0);
618 defer allocator.free(terminated);
619 @memcpy(terminated, text);
620
615 // A title is arbitrary page text, so it goes through the re-encoder: a
616 // NUL would end the string early and an emoji is a surrogate pair to the
617 // JVM.
621618 try j.callVoidMethodA(values, put, &.{
622619 .{ .l = try j.newStringUtf(column) },
623 .{ .l = try j.newStringUtf(terminated.ptr) },
620 .{ .l = try j.newStringUtf8(allocator, text) },
624621 });
625622}
626623
packages/zig/src/bridge_android_clipboard.zigmodified+2-2
Changes to packages/zig/src/bridge_android_clipboard.zig
@@ -120,7 +120,7 @@ pub fn read(allocator: std.mem.Allocator, j: Jni, activity: jobject) ![]u8 {
120120/// The label is `"Craft"`, and it is user-visible: Android 13 and later show a
121121/// preview toast naming it. Changing it because Zig now does the writing would
122122/// change what the user sees.
123pub fn write(j: Jni, activity: jobject, text: [*:0]const u8) !void {
123pub fn write(j: Jni, allocator: std.mem.Allocator, activity: jobject, text: []const u8) !void {
124124 try j.pushLocalFrame(16);
125125 defer _ = j.popLocalFrame(null);
126126
@@ -129,7 +129,7 @@ pub fn write(j: Jni, activity: jobject, text: [*:0]const u8) !void {
129129
130130 const clip_cls = try j.findClass("android/content/ClipData");
131131 const label = try j.newStringUtf("Craft");
132 const value = try j.newStringUtf(text);
132 const value = try j.newStringUtf8(allocator, text);
133133
134134 // `newPlainText` takes two CharSequences; a String is one, and the JVM
135135 // accepts the subtype without a cast.
packages/zig/src/bridge_android_contacts.zigmodified+8-15
Changes to packages/zig/src/bridge_android_contacts.zig
@@ -270,7 +270,7 @@ fn queryUri(
270270 for (args, 0..) |arg, i| {
271271 // A null argument is a null element, which is what
272272 // `arrayOf(contactId)` produces when the id was null.
273 const value: jni.jstring = if (arg) |text| try javaString(j, allocator, text) else null;
273 const value: jni.jstring = if (arg) |text| try j.newStringUtf8(allocator, text) else null;
274274 try j.setObjectArrayElement(array, i, value);
275275 }
276276 break :blk array;
@@ -286,9 +286,9 @@ fn queryUri(
286286 &.{
287287 .{ .l = uri },
288288 .{ .l = null },
289 .{ .l = if (selection) |text| try javaString(j, allocator, text) else null },
289 .{ .l = if (selection) |text| try j.newStringUtf8(allocator, text) else null },
290290 .{ .l = args_array },
291 .{ .l = if (sort_order) |text| try javaString(j, allocator, text) else null },
291 .{ .l = if (sort_order) |text| try j.newStringUtf8(allocator, text) else null },
292292 },
293293 );
294294}
@@ -300,7 +300,7 @@ fn columnIndex(j: Jni, allocator: std.mem.Allocator, cursor: jobject, name: []co
300300 return j.callIntMethodA(
301301 cursor,
302302 try j.methodId(try j.objectClass(cursor), "getColumnIndexOrThrow", "(Ljava/lang/String;)I"),
303 &.{.{ .l = try javaString(j, allocator, name) }},
303 &.{.{ .l = try j.newStringUtf8(allocator, name) }},
304304 );
305305}
306306
@@ -316,13 +316,6 @@ fn columnText(
316316 return try j.stringToUtf8(allocator, value);
317317}
318318
319fn javaString(j: Jni, allocator: std.mem.Allocator, text: []const u8) !jni.jstring {
320 const terminated = try allocator.allocSentinel(u8, text.len, 0);
321 defer allocator.free(terminated);
322 @memcpy(terminated, text);
323 return j.newStringUtf(terminated.ptr);
324}
325
326319// =============================================================================
327320// addContact
328321// =============================================================================
@@ -471,7 +464,7 @@ pub fn addContact(j: Jni, allocator: std.mem.Allocator, activity: jobject, conta
471464 "(Ljava/lang/String;Ljava/util/ArrayList;)[Landroid/content/ContentProviderResult;",
472465 ),
473466 &.{
474 .{ .l = try javaString(j, allocator, contacts_authority) },
467 .{ .l = try j.newStringUtf8(allocator, contacts_authority) },
475468 .{ .l = ops },
476469 },
477470 );
@@ -535,11 +528,11 @@ fn appendDataRow(
535528 "withValueBackReference",
536529 "(Ljava/lang/String;I)Landroid/content/ContentProviderOperation$Builder;",
537530 ),
538 &.{ .{ .l = try javaString(j, allocator, col_raw_contact_id) }, .{ .i = 0 } },
531 &.{ .{ .l = try j.newStringUtf8(allocator, col_raw_contact_id) }, .{ .i = 0 } },
539532 );
540533
541534 _ = try withValue(j, allocator, builder, col_mimetype, item_type);
542 _ = try withValue(j, allocator, builder, row.value_column, try javaString(j, allocator, row.value));
535 _ = try withValue(j, allocator, builder, row.value_column, try j.newStringUtf8(allocator, row.value));
543536
544537 if (row.type_value) |type_value| {
545538 const integer_cls = try j.findClass("java/lang/Integer");
@@ -581,7 +574,7 @@ fn withValue(
581574 "withValue",
582575 "(Ljava/lang/String;Ljava/lang/Object;)Landroid/content/ContentProviderOperation$Builder;",
583576 ),
584 &.{ .{ .l = try javaString(j, allocator, column) }, .{ .l = value } },
577 &.{ .{ .l = try j.newStringUtf8(allocator, column) }, .{ .l = value } },
585578 );
586579}
587580
packages/zig/src/bridge_android_db.zigmodified+3-10
Changes to packages/zig/src/bridge_android_db.zig
@@ -136,7 +136,7 @@ pub fn execute(j: Jni, allocator: std.mem.Allocator, db: jobject, sql: []const u
136136 try j.callVoidMethodA(
137137 db,
138138 try j.methodId(db_cls, "execSQL", "(Ljava/lang/String;[Ljava/lang/Object;)V"),
139 &.{ .{ .l = try javaString(j, allocator, sql) }, .{ .l = bind } },
139 &.{ .{ .l = try j.newStringUtf8(allocator, sql) }, .{ .l = bind } },
140140 );
141141}
142142
@@ -161,7 +161,7 @@ pub fn query(
161161 "rawQuery",
162162 "(Ljava/lang/String;[Ljava/lang/String;)Landroid/database/Cursor;",
163163 ),
164 &.{ .{ .l = try javaString(j, allocator, sql) }, .{ .l = bind } },
164 &.{ .{ .l = try j.newStringUtf8(allocator, sql) }, .{ .l = bind } },
165165 );
166166
167167 try out.append(allocator, '[');
@@ -212,20 +212,13 @@ pub fn query(
212212 }
213213}
214214
215fn javaString(j: Jni, allocator: std.mem.Allocator, text: []const u8) !jni.jstring {
216 const terminated = try allocator.allocSentinel(u8, text.len, 0);
217 defer allocator.free(terminated);
218 @memcpy(terminated, text);
219 return j.newStringUtf(terminated.ptr);
220}
221
222215fn stringArray(j: Jni, allocator: std.mem.Allocator, values: []const []const u8) !jobject {
223216 const string_cls = try j.findClass("java/lang/String");
224217 const array = try j.newObjectArray(values.len, string_cls);
225218 for (values, 0..) |value, i| {
226219 try j.pushLocalFrame(4);
227220 defer _ = j.popLocalFrame(null);
228 try j.setObjectArrayElement(array, i, try javaString(j, allocator, value));
221 try j.setObjectArrayElement(array, i, try j.newStringUtf8(allocator, value));
229222 }
230223 return array;
231224}
packages/zig/src/bridge_android_intents.zigmodified+18-11
Changes to packages/zig/src/bridge_android_intents.zig
@@ -66,12 +66,12 @@ fn startActivity(j: Jni, activity: jobject, intent: jobject) !void {
6666/// produces a Uri with a null scheme for nonsense. The failure that matters
6767/// comes later, from `startActivity` finding nothing to handle the result, and
6868/// that arrives here as `JavaException` from the `check` after the call.
69pub fn openUrl(j: Jni, activity: jobject, url: [*:0]const u8) !void {
69pub fn openUrl(j: Jni, allocator: std.mem.Allocator, activity: jobject, url: []const u8) !void {
7070 try j.pushLocalFrame(16);
7171 defer _ = j.popLocalFrame(null);
7272
7373 const uri_cls = try j.findClass("android/net/Uri");
74 const url_string = try j.newStringUtf(url);
74 const url_string = try j.newStringUtf8(allocator, url);
7575 const uri = try j.callStaticObjectMethodA(
7676 uri_cls,
7777 try j.staticMethodId(uri_cls, "parse", "(Ljava/lang/String;)Landroid/net/Uri;"),
@@ -93,7 +93,7 @@ pub fn openUrl(j: Jni, activity: jobject, url: [*:0]const u8) !void {
9393///
9494/// An empty `title` skips `EXTRA_SUBJECT` entirely rather than setting it to
9595/// the empty string — see the module comment.
96pub fn share(j: Jni, activity: jobject, text: [*:0]const u8, title: [*:0]const u8) !void {
96pub fn share(j: Jni, allocator: std.mem.Allocator, activity: jobject, text: []const u8, title: []const u8) !void {
9797 try j.pushLocalFrame(24);
9898 defer _ = j.popLocalFrame(null);
9999
@@ -122,12 +122,12 @@ pub fn share(j: Jni, activity: jobject, text: [*:0]const u8, title: [*:0]const u
122122 );
123123
124124 const extra_text = try intentConstant(j, intent_cls, "EXTRA_TEXT");
125 const body = try j.newStringUtf(text);
125 const body = try j.newStringUtf8(allocator, text);
126126 _ = try j.callObjectMethodA(intent, put_extra, &.{ .{ .l = extra_text }, .{ .l = body } });
127127
128 if (title[0] != 0) {
128 if (title.len != 0) {
129129 const extra_subject = try intentConstant(j, intent_cls, "EXTRA_SUBJECT");
130 const subject = try j.newStringUtf(title);
130 const subject = try j.newStringUtf8(allocator, title);
131131 _ = try j.callObjectMethodA(intent, put_extra, &.{ .{ .l = extra_subject }, .{ .l = subject } });
132132 }
133133
@@ -188,7 +188,12 @@ fn fStaticObjectField(_: jni.JNIEnv, _: jni.jclass, _: jni.jfieldID) callconv(.c
188188 return obj(2);
189189}
190190fn fNewStringUTF(_: jni.JNIEnv, text: [*:0]const u8) callconv(.c) jni.jstring {
191 fake_strings.append(testing.allocator, std.mem.span(text)) catch {};
191 // Copied, not borrowed. The caller re-encodes into a scratch buffer and
192 // frees it as soon as the JVM has the string — which is what a real
193 // `NewStringUTF` licenses, and what made this fake read freed memory when
194 // it held the pointer.
195 const copy = testing.allocator.dupe(u8, std.mem.span(text)) catch return obj(3);
196 fake_strings.append(testing.allocator, copy) catch testing.allocator.free(copy);
192197 return obj(3);
193198}
194199fn fCallStaticObjectMethodA(_: jni.JNIEnv, _: jni.jclass, id: jni.jmethodID, _: [*]const jni.jvalue) callconv(.c) jobject {
@@ -246,6 +251,7 @@ fn fakeEnv(table: *jni.JNINativeInterface) void {
246251fn resetFakes() void {
247252 fake_calls.clearRetainingCapacity();
248253 fake_field_names.clearRetainingCapacity();
254 for (fake_strings.items) |text| testing.allocator.free(text);
249255 fake_strings.clearRetainingCapacity();
250256 fake_throw_on_start = false;
251257 fake_pending = null;
@@ -254,6 +260,7 @@ fn resetFakes() void {
254260fn freeFakes() void {
255261 fake_calls.deinit(testing.allocator);
256262 fake_field_names.deinit(testing.allocator);
263 for (fake_strings.items) |text| testing.allocator.free(text);
257264 fake_strings.deinit(testing.allocator);
258265 fake_calls = .empty;
259266 fake_field_names = .empty;
@@ -280,7 +287,7 @@ test "openURL parses the url and hands the intent to the activity" {
280287 fakeEnv(&table);
281288 const ptr: *const jni.JNINativeInterface = &table;
282289
283 try openUrl(Jni.init(&ptr), obj(8), "https://example.com/x?y=1");
290 try openUrl(Jni.init(&ptr), testing.allocator, obj(8), "https://example.com/x?y=1");
284291
285292 try testing.expect(sawCall("parse"));
286293 try testing.expect(sawCall("<init>"));
@@ -303,7 +310,7 @@ test "a URL nothing can open reports the exception rather than success" {
303310 fake_throw_on_start = true;
304311 try testing.expectError(
305312 jni.JniError.JavaException,
306 openUrl(Jni.init(&ptr), obj(8), "definitely-not-a-scheme://x"),
313 openUrl(Jni.init(&ptr), testing.allocator, obj(8), "definitely-not-a-scheme://x"),
307314 );
308315}
309316
@@ -314,7 +321,7 @@ test "share sets the text extra and the chooser" {
314321 fakeEnv(&table);
315322 const ptr: *const jni.JNINativeInterface = &table;
316323
317 try share(Jni.init(&ptr), obj(8), "hello", "Subject");
324 try share(Jni.init(&ptr), testing.allocator, obj(8), "hello", "Subject");
318325
319326 try testing.expect(sawField("ACTION_SEND"));
320327 try testing.expect(sawField("EXTRA_TEXT"));
@@ -337,7 +344,7 @@ test "an empty title omits the subject rather than setting it empty" {
337344 fakeEnv(&table);
338345 const ptr: *const jni.JNINativeInterface = &table;
339346
340 try share(Jni.init(&ptr), obj(8), "hello", "");
347 try share(Jni.init(&ptr), testing.allocator, obj(8), "hello", "");
341348
342349 try testing.expect(sawField("EXTRA_TEXT"));
343350 try testing.expect(!sawField("EXTRA_SUBJECT"));
packages/zig/src/bridge_android_securestore.zigmodified+6-6
Changes to packages/zig/src/bridge_android_securestore.zig
@@ -78,7 +78,7 @@ fn apply(j: Jni, edit: jobject) !void {
7878 try j.callVoidMethodA(edit, try j.methodId(edit_cls, "apply", "()V"), &.{});
7979}
8080
81pub fn set(j: Jni, prefs: jobject, key: [*:0]const u8, value: [*:0]const u8) !void {
81pub fn set(j: Jni, allocator: std.mem.Allocator, prefs: jobject, key: []const u8, value: []const u8) !void {
8282 try j.pushLocalFrame(16);
8383 defer _ = j.popLocalFrame(null);
8484
@@ -92,12 +92,12 @@ pub fn set(j: Jni, prefs: jobject, key: [*:0]const u8, value: [*:0]const u8) !vo
9292 "putString",
9393 "(Ljava/lang/String;Ljava/lang/String;)Landroid/content/SharedPreferences$Editor;",
9494 ),
95 &.{ .{ .l = try j.newStringUtf(key) }, .{ .l = try j.newStringUtf(value) } },
95 &.{ .{ .l = try j.newStringUtf8(allocator, key) }, .{ .l = try j.newStringUtf8(allocator, value) } },
9696 );
9797 try apply(j, edit);
9898}
9999
100pub fn remove(j: Jni, prefs: jobject, key: [*:0]const u8) !void {
100pub fn remove(j: Jni, allocator: std.mem.Allocator, prefs: jobject, key: []const u8) !void {
101101 try j.pushLocalFrame(16);
102102 defer _ = j.popLocalFrame(null);
103103
@@ -107,7 +107,7 @@ pub fn remove(j: Jni, prefs: jobject, key: [*:0]const u8) !void {
107107 _ = try j.callObjectMethodA(
108108 edit,
109109 try j.methodId(edit_cls, "remove", "(Ljava/lang/String;)Landroid/content/SharedPreferences$Editor;"),
110 &.{.{ .l = try j.newStringUtf(key) }},
110 &.{.{ .l = try j.newStringUtf8(allocator, key) }},
111111 );
112112 try apply(j, edit);
113113}
@@ -147,7 +147,7 @@ pub fn renderRead(allocator: std.mem.Allocator, value: ?[]const u8) ![]u8 {
147147}
148148
149149/// `prefs.getString(key, null)`, as an owned optional. Caller frees.
150pub fn get(allocator: std.mem.Allocator, j: Jni, prefs: jobject, key: [*:0]const u8) !?[]u8 {
150pub fn get(allocator: std.mem.Allocator, j: Jni, prefs: jobject, key: []const u8) !?[]u8 {
151151 try j.pushLocalFrame(16);
152152 defer _ = j.popLocalFrame(null);
153153
@@ -161,7 +161,7 @@ pub fn get(allocator: std.mem.Allocator, j: Jni, prefs: jobject, key: [*:0]const
161161 ),
162162 // The default is null, which is what makes "absent" distinguishable
163163 // from a stored empty string.
164 &.{ .{ .l = try j.newStringUtf(key) }, .{ .l = null } },
164 &.{ .{ .l = try j.newStringUtf8(allocator, key) }, .{ .l = null } },
165165 );
166166 if (value == null) return null;
167167 return try j.stringToUtf8(allocator, value);
packages/zig/src/bridge_android_shareditem.zigmodified+5-12
Changes to packages/zig/src/bridge_android_shareditem.zig
@@ -106,7 +106,7 @@ fn openPrefs(j: Jni, allocator: std.mem.Allocator, activity: jobject, group: []c
106106 "getSharedPreferences",
107107 "(Ljava/lang/String;I)Landroid/content/SharedPreferences;",
108108 ),
109 &.{ .{ .l = try j.newStringUtf(name.ptr) }, .{ .i = mode_private } },
109 &.{ .{ .l = try j.newStringUtf8(allocator, name) }, .{ .i = mode_private } },
110110 );
111111}
112112
@@ -134,8 +134,8 @@ pub fn set(
134134 "(Ljava/lang/String;Ljava/lang/String;)Landroid/content/SharedPreferences$Editor;",
135135 ),
136136 &.{
137 .{ .l = try javaString(j, allocator, key) },
138 .{ .l = try javaString(j, allocator, value) },
137 .{ .l = try j.newStringUtf8(allocator, key) },
138 .{ .l = try j.newStringUtf8(allocator, value) },
139139 },
140140 );
141141
@@ -167,7 +167,7 @@ pub fn remove(
167167 "remove",
168168 "(Ljava/lang/String;)Landroid/content/SharedPreferences$Editor;",
169169 ),
170 &.{.{ .l = try javaString(j, allocator, key) }},
170 &.{.{ .l = try j.newStringUtf8(allocator, key) }},
171171 );
172172 try j.callVoidMethodA(editor, try j.methodId(editor_cls, "apply", "()V"), &.{});
173173}
@@ -194,7 +194,7 @@ pub fn get(
194194 "getString",
195195 "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;",
196196 ),
197 &.{ .{ .l = try javaString(j, allocator, key) }, .{ .l = null } },
197 &.{ .{ .l = try j.newStringUtf8(allocator, key) }, .{ .l = null } },
198198 );
199199
200200 if (value == null) return null;
@@ -212,13 +212,6 @@ fn editorFor(j: Jni, prefs: jobject) !jobject {
212212 );
213213}
214214
215fn javaString(j: Jni, allocator: std.mem.Allocator, text: []const u8) !jni.jstring {
216 const terminated = try allocator.allocSentinel(u8, text.len, 0);
217 defer allocator.free(terminated);
218 @memcpy(terminated, text);
219 return j.newStringUtf(terminated.ptr);
220}
221
222215// =============================================================================
223216// Tests
224217// =============================================================================
packages/zig/src/bridge_android_system.zigmodified+2-2
Changes to packages/zig/src/bridge_android_system.zig
@@ -135,13 +135,13 @@ pub fn readMemory(j: Jni) !MemoryUsage {
135135/// `CraftBridge` is filtering on a string, and changing it because Zig now
136136/// emits the line would break a filter that has nothing to do with this
137137/// migration.
138pub fn writeLog(j: Jni, message: [*:0]const u8) !void {
138pub fn writeLog(j: Jni, allocator: std.mem.Allocator, message: []const u8) !void {
139139 try j.pushLocalFrame(8);
140140 defer _ = j.popLocalFrame(null);
141141
142142 const cls = try j.findClass("android/util/Log");
143143 const tag = try j.newStringUtf("CraftBridge");
144 const text = try j.newStringUtf(message);
144 const text = try j.newStringUtf8(allocator, message);
145145
146146 // `Log.d` returns the number of bytes written, which the Kotlin ignores
147147 // and so does this. Discarding it is not the same as calling a void
packages/zig/src/bridge_android_widgets.zigmodified+2-9
Changes to packages/zig/src/bridge_android_widgets.zig
@@ -137,7 +137,7 @@ pub fn writeUpdate(j: Jni, allocator: std.mem.Allocator, activity: jobject, upda
137137 defer _ = j.popLocalFrame(null);
138138 _ = try j.callObjectMethodA(editor, put_string, &.{
139139 .{ .l = try j.newStringUtf(field.key) },
140 .{ .l = try javaString(j, allocator, text) },
140 .{ .l = try j.newStringUtf8(allocator, text) },
141141 });
142142 }
143143 }
@@ -156,7 +156,7 @@ pub fn broadcast(j: Jni, allocator: std.mem.Allocator, activity: jobject, action
156156 const intent = try j.newObjectA(
157157 intent_cls,
158158 try j.methodId(intent_cls, "<init>", "(Ljava/lang/String;)V"),
159 &.{.{ .l = try javaString(j, allocator, action) }},
159 &.{.{ .l = try j.newStringUtf8(allocator, action) }},
160160 );
161161
162162 const activity_cls = try j.objectClass(activity);
@@ -181,13 +181,6 @@ pub fn broadcast(j: Jni, allocator: std.mem.Allocator, activity: jobject, action
181181 );
182182}
183183
184fn javaString(j: Jni, allocator: std.mem.Allocator, text: []const u8) !jni.jstring {
185 const terminated = try allocator.allocSentinel(u8, text.len, 0);
186 defer allocator.free(terminated);
187 @memcpy(terminated, text);
188 return j.newStringUtf(terminated.ptr);
189}
190
191184// =============================================================================
192185// Tests
193186// =============================================================================
packages/zig/src/jni_runtime.zigmodified+190-7
Changes to packages/zig/src/jni_runtime.zig
@@ -477,6 +477,9 @@ pub const JniError = error{
477477 StringUnavailable,
478478 /// The JVM handed back bytes that are not well-formed modified UTF-8.
479479 MalformedString,
480 /// Text on its way *to* the JVM that is not well-formed UTF-8. A bug in
481 /// whatever built it, rather than something a device did.
482 InvalidUtf8,
480483 OutOfMemory,
481484};
482485
@@ -779,14 +782,14 @@ pub const Jni = struct {
779782 try self.check();
780783 }
781784
782 /// Make a Java `String` from UTF-8.
785 /// Make a Java `String` from text already in *modified* UTF-8.
783786 ///
784 /// The JVM wants *modified* UTF-8 here, the same encoding `stringToUtf8`
785 /// decodes on the way back — so a string carrying a NUL or an astral
786 /// character has to be re-encoded rather than handed over. Callers pass
787 /// NUL-terminated literals and ASCII package names today, which are
788 /// identical in both encodings; `encodeModifiedUtf8` is the function to
789 /// add when that stops being true, and this is where it goes.
787 /// Use this only for ASCII the source file holds as a literal — a class
788 /// name, a column name, a preference key. Anything that came from the page
789 /// or from the device goes through `newStringUtf8`, which re-encodes:
790 /// standard UTF-8 and modified UTF-8 agree on ASCII and disagree on a NUL
791 /// and on every character outside the BMP, and `NewStringUTF` given a
792 /// four-byte sequence does not produce the string that was meant.
790793 pub fn newStringUtf(self: Self, text: [*:0]const u8) JniError!jstring {
791794 const new: *const fn (JNIEnv, [*:0]const u8) callconv(.c) jstring =
792795 @ptrCast(self.table().NewStringUTF orelse return JniError.NotFound);
@@ -795,6 +798,22 @@ pub const Jni = struct {
795798 return str orelse JniError.OutOfMemory;
796799 }
797800
801 /// Make a Java `String` from arbitrary UTF-8.
802 ///
803 /// This is the one to reach for whenever the text is not a literal in this
804 /// repository: a contact's name, a widget's title, a SQL statement, a
805 /// preference value. `NewStringUTF` reads *modified* UTF-8, so an emoji —
806 /// four bytes in the standard encoding, two three-byte halves in Java's —
807 /// has to be re-encoded rather than handed over, and a NUL has to become
808 /// `C0 80` or the string ends early.
809 ///
810 /// The allocation is freed before returning; the `jstring` is the JVM's.
811 pub fn newStringUtf8(self: Self, allocator: std.mem.Allocator, text: []const u8) JniError!jstring {
812 const encoded = try encodeModifiedUtf8(allocator, text);
813 defer allocator.free(encoded);
814 return self.newStringUtf(encoded.ptr);
815 }
816
798817 pub fn callIntMethod(self: Self, obj: jobject, id: jmethodID) JniError!jint {
799818 const call: *const fn (JNIEnv, jobject, jmethodID) callconv(.c) jint =
800819 @ptrCast(self.table().CallIntMethod orelse return JniError.NotFound);
@@ -966,6 +985,81 @@ fn decodeThreeByte(bytes: *const [3]u8) u32 {
966985 @as(u32, bytes[2] & 0x3F);
967986}
968987
988/// Convert standard UTF-8 to Java's modified UTF-8, NUL-terminated.
989///
990/// The inverse of `decodeModifiedUtf8`, and needed for the same two reasons in
991/// the other direction:
992///
993/// - **U+0000 becomes `C0 80`**, so the result can be NUL-terminated without
994/// the terminator being ambiguous. `NewStringUTF` takes a C string, so a
995/// text carrying a NUL would otherwise be truncated at it silently.
996/// - **A character outside the BMP becomes two three-byte sequences**, one
997/// per UTF-16 surrogate. Handing `NewStringUTF` the standard four-byte form
998/// does not produce the character that was meant — an emoji in a contact's
999/// name is the everyday case.
1000///
1001/// Invalid UTF-8 is an error rather than something to pass through. Every
1002/// caller's text either came from `stringToUtf8`, which produces valid UTF-8,
1003/// or was built here — so invalid input means a bug upstream, and quietly
1004/// writing malformed bytes into the JVM would hide it.
1005pub fn encodeModifiedUtf8(allocator: std.mem.Allocator, input: []const u8) JniError![:0]u8 {
1006 // Validated once rather than per sequence, so the loop below can copy the
1007 // two- and three-byte forms without re-deciding whether they are legal.
1008 // This is also what refuses a lone surrogate: valid CESU-8, and not UTF-8,
1009 // so it can only have come from something already confused about which
1010 // encoding it holds.
1011 if (!std.unicode.utf8ValidateSlice(input)) return JniError.InvalidUtf8;
1012
1013 var out: std.ArrayListUnmanaged(u8) = .empty;
1014 errdefer out.deinit(allocator);
1015
1016 var i: usize = 0;
1017 while (i < input.len) {
1018 const len = std.unicode.utf8ByteSequenceLength(input[i]) catch return JniError.InvalidUtf8;
1019 const bytes = input[i .. i + len];
1020 i += len;
1021
1022 switch (len) {
1023 1 => {
1024 if (bytes[0] == 0) {
1025 // The overlong form Java uses, and standard UTF-8 forbids.
1026 out.appendSlice(allocator, &.{ 0xC0, 0x80 }) catch return JniError.OutOfMemory;
1027 } else {
1028 out.append(allocator, bytes[0]) catch return JniError.OutOfMemory;
1029 }
1030 },
1031 2, 3 => out.appendSlice(allocator, bytes) catch return JniError.OutOfMemory,
1032 4 => {
1033 const codepoint = std.unicode.utf8Decode(bytes) catch return JniError.InvalidUtf8;
1034 const offset = codepoint - 0x10000;
1035 const high: u32 = 0xD800 + (offset >> 10);
1036 const low: u32 = 0xDC00 + (offset & 0x3FF);
1037 appendThreeByte(allocator, &out, high) catch return JniError.OutOfMemory;
1038 appendThreeByte(allocator, &out, low) catch return JniError.OutOfMemory;
1039 },
1040 else => return JniError.InvalidUtf8,
1041 }
1042 }
1043
1044 return out.toOwnedSliceSentinel(allocator, 0) catch JniError.OutOfMemory;
1045}
1046
1047/// One UTF-16 code unit as the three-byte form, surrogates included.
1048///
1049/// `std.unicode.utf8Encode` refuses a lone surrogate, which is precisely what
1050/// each half of a CESU-8 pair is — so the bytes are written directly.
1051fn appendThreeByte(
1052 allocator: std.mem.Allocator,
1053 out: *std.ArrayListUnmanaged(u8),
1054 unit: u32,
1055) !void {
1056 try out.appendSlice(allocator, &.{
1057 @intCast(0xE0 | (unit >> 12)),
1058 @intCast(0x80 | ((unit >> 6) & 0x3F)),
1059 @intCast(0x80 | (unit & 0x3F)),
1060 });
1061}
1062
9691063pub fn decodeModifiedUtf8(allocator: std.mem.Allocator, input: []const u8) JniError![]u8 {
9701064 var out: std.ArrayListUnmanaged(u8) = .empty;
9711065 errdefer out.deinit(allocator);
@@ -1394,3 +1488,92 @@ test "a field read that throws reports the exception, not the value" {
13941488 var obj: u8 = 0;
13951489 try testing.expectError(JniError.JavaException, jni.intField(@ptrCast(&obj), null));
13961490}
1491
1492test "encoding is the exact inverse of decoding" {
1493 // The property that matters: whatever goes to the JVM comes back the same.
1494 // Written as a round trip rather than as byte assertions, because the two
1495 // functions are the pair that has to agree — and a shared misunderstanding
1496 // of the format would survive either one being tested alone.
1497 for ([_][]const u8{
1498 "",
1499 "plain ascii",
1500 "café",
1501 "日本語",
1502 "🙂",
1503 "a🙂b",
1504 "🙂🙂",
1505 // The astral range's ends.
1506 "\u{10000}",
1507 "\u{10FFFF}",
1508 // BMP characters either side of the surrogate block, which must pass
1509 // through as three bytes rather than being re-paired.
1510 "\u{D7FF}\u{E000}",
1511 "mixed café 🙂 日本語 x",
1512 }) |original| {
1513 const encoded = try encodeModifiedUtf8(testing.allocator, original);
1514 defer testing.allocator.free(encoded);
1515
1516 const decoded = try decodeModifiedUtf8(testing.allocator, encoded);
1517 defer testing.allocator.free(decoded);
1518
1519 try testing.expectEqualStrings(original, decoded);
1520 }
1521}
1522
1523test "a NUL becomes C0 80, so the terminator stays unambiguous" {
1524 // `NewStringUTF` takes a C string. A text carrying a NUL encoded as one
1525 // byte would be truncated there and nothing would say so.
1526 const encoded = try encodeModifiedUtf8(testing.allocator, "a\x00b");
1527 defer testing.allocator.free(encoded);
1528
1529 try testing.expectEqualSlices(u8, &.{ 'a', 0xC0, 0x80, 'b' }, encoded);
1530 try testing.expectEqual(@as(usize, 4), encoded.len);
1531 try testing.expectEqual(@as(u8, 0), encoded.ptr[encoded.len]);
1532
1533 // And the NUL survives the round trip rather than ending the string.
1534 const decoded = try decodeModifiedUtf8(testing.allocator, encoded);
1535 defer testing.allocator.free(decoded);
1536 try testing.expectEqualSlices(u8, &.{ 'a', 0, 'b' }, decoded);
1537}
1538
1539test "an astral character becomes two three-byte halves" {
1540 // U+1F642, which JSON.stringify hands over as four bytes and Java holds as
1541 // a surrogate pair. Asserted as bytes here because this is the case where
1542 // handing NewStringUTF the standard form produces the wrong string, and a
1543 // round trip through both of our own functions could not tell.
1544 const encoded = try encodeModifiedUtf8(testing.allocator, "🙂");
1545 defer testing.allocator.free(encoded);
1546
1547 // U+1F642 - 0x10000 = 0xF642; high = D800 + (F642 >> 10) = D83D,
1548 // low = DC00 + (F642 & 3FF) = DE42.
1549 try testing.expectEqualSlices(u8, &.{
1550 0xED, 0xA0, 0xBD, // D83D
1551 0xED, 0xB9, 0x82, // DE42
1552 }, encoded);
1553 try testing.expectEqual(@as(usize, 6), encoded.len);
1554}
1555
1556test "ASCII and BMP text pass through unchanged" {
1557 // The reason `newStringUtf` on a literal is still correct: the two
1558 // encodings agree everywhere except a NUL and the astral planes.
1559 for ([_][]const u8{ "SELECT 1", "craft_widget_prefs", "café", "日本語" }) |text| {
1560 const encoded = try encodeModifiedUtf8(testing.allocator, text);
1561 defer testing.allocator.free(encoded);
1562 try testing.expectEqualStrings(text, encoded);
1563 }
1564}
1565
1566test "invalid UTF-8 is refused rather than written into the JVM" {
1567 // Every caller's text either came from `stringToUtf8` or was built here,
1568 // so this means a bug upstream — and malformed bytes reaching the JVM
1569 // would hide it somewhere much harder to read.
1570 for ([_][]const u8{
1571 &.{0x80}, // a continuation byte with nothing to continue
1572 &.{0xC3}, // a two-byte lead with no second byte
1573 &.{ 0xE2, 0x82 }, // a three-byte sequence cut short
1574 &.{0xF8}, // a five-byte lead, which UTF-8 does not have
1575 &.{ 0xED, 0xA0, 0xBD }, // a lone surrogate, valid CESU-8 and not UTF-8
1576 }) |bad| {
1577 try testing.expectError(JniError.InvalidUtf8, encodeModifiedUtf8(testing.allocator, bad));
1578 }
1579}