also looking at this
fix(android): text going to the JVM is modified UTF-8, and was not being
#168
12 files
+326
-136
| @@ -359,14 +359,10 @@ fn nativeGetDeviceInfo(env: jni.JNIEnv, _: jni.jobject, activity: jni.jobject) c | ||
| 359 | 359 | |
| 360 | 360 | const json = device.render(allocator, info) catch return null; |
| 361 | 361 | |
| 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; | |
| 370 | 366 | } |
| 371 | 367 | |
| 372 | 368 | /// `nativeGetMemoryUsage()` — the JVM heap, as JSON. |
| @@ -383,10 +379,7 @@ fn nativeGetMemoryUsage(env: jni.JNIEnv, _: jni.jobject) callconv(.c) jni.jstrin | ||
| 383 | 379 | }; |
| 384 | 380 | |
| 385 | 381 | 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; | |
| 390 | 383 | } |
| 391 | 384 | |
| 392 | 385 | /// `nativeLog(message)` — returns whether Zig wrote the line. |
| @@ -402,10 +395,8 @@ fn nativeLog(env: jni.JNIEnv, _: jni.jobject, message: jni.jstring) callconv(.c) | ||
| 402 | 395 | const allocator = arena.allocator(); |
| 403 | 396 | |
| 404 | 397 | 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); | |
| 407 | 398 | |
| 408 | system.writeLog(j, owned.ptr) catch |err| { | |
| 399 | system.writeLog(j, allocator, text) catch |err| { | |
| 409 | 400 | std.log.warn("craft: log fell through to the shim ({s})", .{@errorName(err)}); |
| 410 | 401 | return jni.JNI_FALSE; |
| 411 | 402 | }; |
| @@ -429,9 +420,7 @@ fn nativeClipboardRead(env: jni.JNIEnv, _: jni.jobject, activity: jni.jobject) c | ||
| 429 | 420 | return null; |
| 430 | 421 | }; |
| 431 | 422 | |
| 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; | |
| 435 | 424 | } |
| 436 | 425 | |
| 437 | 426 | /// `nativeClipboardWrite(activity, text)`. |
| @@ -448,25 +437,21 @@ fn nativeClipboardWrite( | ||
| 448 | 437 | const allocator = arena.allocator(); |
| 449 | 438 | |
| 450 | 439 | 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); | |
| 453 | 440 | |
| 454 | clipboard.write(j, activity, owned.ptr) catch |err| { | |
| 441 | clipboard.write(j, allocator, activity, value) catch |err| { | |
| 455 | 442 | std.log.warn("craft: clipboardWrite fell through to the shim ({s})", .{@errorName(err)}); |
| 456 | 443 | return jni.JNI_FALSE; |
| 457 | 444 | }; |
| 458 | 445 | return jni.JNI_TRUE; |
| 459 | 446 | } |
| 460 | 447 | |
| 461 | /// Copy a `jstring` into NUL-terminated arena memory, or null. | |
| 448 | /// A `jstring` as arena-owned UTF-8, or null. | |
| 462 | 449 | /// |
| 463 | 450 | /// Every native below needs this and none of them can share the result, since |
| 464 | /// each arena dies with its call. | |
| 465 | fn 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. | |
| 453 | fn ownedUtf8(j: Jni, allocator: std.mem.Allocator, str: jni.jstring) ?[]u8 { | |
| 454 | return j.stringToUtf8(allocator, str) catch null; | |
| 470 | 455 | } |
| 471 | 456 | |
| 472 | 457 | fn nativeOpenUrl( |
| @@ -479,8 +464,10 @@ fn nativeOpenUrl( | ||
| 479 | 464 | var arena = std.heap.ArenaAllocator.init(backing); |
| 480 | 465 | defer arena.deinit(); |
| 481 | 466 | |
| 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| { | |
| 484 | 471 | // Not a warning. `openURL` answering false is the documented outcome |
| 485 | 472 | // when nothing on the device handles the scheme, and the Kotlin |
| 486 | 473 | // catches exactly this — logging it at warn would put a line in |
| @@ -506,7 +493,7 @@ fn nativeShare( | ||
| 506 | 493 | const body = ownedUtf8(j, allocator, text) orelse return jni.JNI_FALSE; |
| 507 | 494 | const subject = ownedUtf8(j, allocator, title) orelse return jni.JNI_FALSE; |
| 508 | 495 | |
| 509 | intents.share(j, activity, body.ptr, subject.ptr) catch |err| { | |
| 496 | intents.share(j, allocator, activity, body, subject) catch |err| { | |
| 510 | 497 | std.log.warn("craft: share fell through to the shim ({s})", .{@errorName(err)}); |
| 511 | 498 | return jni.JNI_FALSE; |
| 512 | 499 | }; |
| @@ -525,9 +512,7 @@ fn nativeGetNetworkStatus(env: jni.JNIEnv, _: jni.jobject, activity: jni.jobject | ||
| 525 | 512 | }; |
| 526 | 513 | |
| 527 | 514 | 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; | |
| 531 | 516 | } |
| 532 | 517 | |
| 533 | 518 | fn nativeSecureSet( |
| @@ -545,7 +530,7 @@ fn nativeSecureSet( | ||
| 545 | 530 | const k = ownedUtf8(j, allocator, key) orelse return jni.JNI_FALSE; |
| 546 | 531 | const v = ownedUtf8(j, allocator, value) orelse return jni.JNI_FALSE; |
| 547 | 532 | |
| 548 | securestore.set(j, prefs, k.ptr, v.ptr) catch |err| { | |
| 533 | securestore.set(j, allocator, prefs, k, v) catch |err| { | |
| 549 | 534 | std.log.warn("craft: secureSet fell through to the shim ({s})", .{@errorName(err)}); |
| 550 | 535 | return jni.JNI_FALSE; |
| 551 | 536 | }; |
| @@ -569,15 +554,13 @@ fn nativeSecureGet( | ||
| 569 | 554 | const allocator = arena.allocator(); |
| 570 | 555 | |
| 571 | 556 | 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| { | |
| 573 | 558 | std.log.warn("craft: secureGet fell through to the shim ({s})", .{@errorName(err)}); |
| 574 | 559 | return null; |
| 575 | 560 | }; |
| 576 | 561 | |
| 577 | 562 | 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; | |
| 581 | 564 | } |
| 582 | 565 | |
| 583 | 566 | fn nativeSecureRemove( |
| @@ -590,8 +573,10 @@ fn nativeSecureRemove( | ||
| 590 | 573 | var arena = std.heap.ArenaAllocator.init(backing); |
| 591 | 574 | defer arena.deinit(); |
| 592 | 575 | |
| 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| { | |
| 595 | 580 | std.log.warn("craft: secureRemove fell through to the shim ({s})", .{@errorName(err)}); |
| 596 | 581 | return jni.JNI_FALSE; |
| 597 | 582 | }; |
| @@ -99,7 +99,7 @@ fn detach(vm: jni.JavaVM) void { | ||
| 99 | 99 | /// Silent on every failure, and deliberately: the caller is a device callback |
| 100 | 100 | /// with nowhere to report to, and the alternatives are a log per location fix |
| 101 | 101 | /// or an error nobody reads. `droppedCount` is what a diagnosis reads instead. |
| 102 | pub fn evaluate(script: [*:0]const u8) void { | |
| 102 | pub fn evaluate(allocator: std.mem.Allocator, script: []const u8) void { | |
| 103 | 103 | const vm = java_vm orelse { |
| 104 | 104 | dropped += 1; |
| 105 | 105 | return; |
| @@ -111,12 +111,12 @@ pub fn evaluate(script: [*:0]const u8) void { | ||
| 111 | 111 | }; |
| 112 | 112 | defer if (attachment.owned) detach(vm); |
| 113 | 113 | |
| 114 | deliverThrough(attachment.env, script) catch { | |
| 114 | deliverThrough(allocator, attachment.env, script) catch { | |
| 115 | 115 | dropped += 1; |
| 116 | 116 | }; |
| 117 | 117 | } |
| 118 | 118 | |
| 119 | fn deliverThrough(env: jni.JNIEnv, script: [*:0]const u8) !void { | |
| 119 | fn deliverThrough(allocator: std.mem.Allocator, env: jni.JNIEnv, script: []const u8) !void { | |
| 120 | 120 | const j = jni.Jni.init(env); |
| 121 | 121 | |
| 122 | 122 | // 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 { | ||
| 127 | 127 | defer _ = j.popLocalFrame(null); |
| 128 | 128 | |
| 129 | 129 | 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); | |
| 131 | 135 | |
| 132 | 136 | const deliver = try j.staticMethodId(cls, "deliver", "(Ljava/lang/String;)V"); |
| 133 | 137 | try j.callStaticVoidMethodA(cls, deliver, &.{.{ .l = text }}); |
| @@ -152,9 +156,8 @@ pub fn emitEvent( | ||
| 152 | 156 | try out.appendSlice(allocator, "', {detail: "); |
| 153 | 157 | try out.appendSlice(allocator, detail_json); |
| 154 | 158 | try out.appendSlice(allocator, "}));"); |
| 155 | try out.append(allocator, 0); | |
| 156 | 159 | |
| 157 | evaluate(@ptrCast(out.items.ptr)); | |
| 160 | evaluate(allocator, out.items); | |
| 158 | 161 | } |
| 159 | 162 | |
| 160 | 163 | /// `window._craftXResolve && window._craftXResolve(payload)`. |
| @@ -180,9 +183,8 @@ pub fn settle( | ||
| 180 | 183 | try out.append(allocator, '('); |
| 181 | 184 | try out.appendSlice(allocator, payload_json); |
| 182 | 185 | try out.appendSlice(allocator, ");"); |
| 183 | try out.append(allocator, 0); | |
| 184 | 186 | |
| 185 | evaluate(@ptrCast(out.items.ptr)); | |
| 187 | evaluate(allocator, out.items); | |
| 186 | 188 | } |
| 187 | 189 | |
| 188 | 190 | // ============================================================================= |
| @@ -287,7 +289,7 @@ test "a script reaches deliver on an already-attached thread, and nothing is det | ||
| 287 | 289 | setVm(&ptr); |
| 288 | 290 | defer clearVm(); |
| 289 | 291 | |
| 290 | evaluate("window.x = 1;"); | |
| 292 | evaluate(testing.allocator, "window.x = 1;"); | |
| 291 | 293 | try testing.expectEqualStrings("window.x = 1;", delivered()); |
| 292 | 294 | |
| 293 | 295 | // 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" { | ||
| 308 | 310 | defer clearVm(); |
| 309 | 311 | |
| 310 | 312 | fake_already_attached = false; |
| 311 | evaluate("window.y = 2;"); | |
| 313 | evaluate(testing.allocator, "window.y = 2;"); | |
| 312 | 314 | |
| 313 | 315 | try testing.expectEqualStrings("window.y = 2;", delivered()); |
| 314 | 316 | 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" { | ||
| 323 | 325 | // No VM at all — before JNI_OnLoad, or in a build with no runtime linked. |
| 324 | 326 | clearVm(); |
| 325 | 327 | resetDroppedForTest(); |
| 326 | evaluate("window.z = 3;"); | |
| 328 | evaluate(testing.allocator, "window.z = 3;"); | |
| 327 | 329 | try testing.expectEqual(@as(usize, 1), droppedCount()); |
| 328 | 330 | |
| 329 | 331 | // A thread that cannot be attached. |
| @@ -332,7 +334,7 @@ test "every way delivery can fail is counted, not logged and not crashed" { | ||
| 332 | 334 | fake_already_attached = false; |
| 333 | 335 | fake_attach_succeeds = false; |
| 334 | 336 | resetDroppedForTest(); |
| 335 | evaluate("window.z = 3;"); | |
| 337 | evaluate(testing.allocator, "window.z = 3;"); | |
| 336 | 338 | try testing.expectEqual(@as(usize, 1), droppedCount()); |
| 337 | 339 | // Nothing was attached, so nothing may be detached. |
| 338 | 340 | 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" { | ||
| 342 | 344 | fake_attach_succeeds = true; |
| 343 | 345 | fake_find_class_succeeds = false; |
| 344 | 346 | resetDroppedForTest(); |
| 345 | evaluate("window.z = 3;"); | |
| 347 | evaluate(testing.allocator, "window.z = 3;"); | |
| 346 | 348 | try testing.expectEqual(@as(usize, 1), droppedCount()); |
| 347 | 349 | } |
| 348 | 350 | |
| @@ -358,7 +360,7 @@ test "an attached thread is detached even when delivery fails" { | ||
| 358 | 360 | |
| 359 | 361 | fake_already_attached = false; |
| 360 | 362 | fake_find_class_succeeds = false; |
| 361 | evaluate("window.z = 3;"); | |
| 363 | evaluate(testing.allocator, "window.z = 3;"); | |
| 362 | 364 | |
| 363 | 365 | try testing.expectEqual(@as(usize, 1), fake_attach_calls); |
| 364 | 366 | 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" { | ||
| 396 | 398 | delivered(), |
| 397 | 399 | ); |
| 398 | 400 | } |
| 401 | ||
| 402 | test "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 | ||
| 426 | test "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 | } | |
| @@ -612,15 +612,12 @@ fn putString( | ||
| 612 | 612 | try j.pushLocalFrame(4); |
| 613 | 613 | defer _ = j.popLocalFrame(null); |
| 614 | 614 | |
| 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. | |
| 621 | 618 | try j.callVoidMethodA(values, put, &.{ |
| 622 | 619 | .{ .l = try j.newStringUtf(column) }, |
| 623 | .{ .l = try j.newStringUtf(terminated.ptr) }, | |
| 620 | .{ .l = try j.newStringUtf8(allocator, text) }, | |
| 624 | 621 | }); |
| 625 | 622 | } |
| 626 | 623 | |
| @@ -120,7 +120,7 @@ pub fn read(allocator: std.mem.Allocator, j: Jni, activity: jobject) ![]u8 { | ||
| 120 | 120 | /// The label is `"Craft"`, and it is user-visible: Android 13 and later show a |
| 121 | 121 | /// preview toast naming it. Changing it because Zig now does the writing would |
| 122 | 122 | /// change what the user sees. |
| 123 | pub fn write(j: Jni, activity: jobject, text: [*:0]const u8) !void { | |
| 123 | pub fn write(j: Jni, allocator: std.mem.Allocator, activity: jobject, text: []const u8) !void { | |
| 124 | 124 | try j.pushLocalFrame(16); |
| 125 | 125 | defer _ = j.popLocalFrame(null); |
| 126 | 126 | |
| @@ -129,7 +129,7 @@ pub fn write(j: Jni, activity: jobject, text: [*:0]const u8) !void { | ||
| 129 | 129 | |
| 130 | 130 | const clip_cls = try j.findClass("android/content/ClipData"); |
| 131 | 131 | const label = try j.newStringUtf("Craft"); |
| 132 | const value = try j.newStringUtf(text); | |
| 132 | const value = try j.newStringUtf8(allocator, text); | |
| 133 | 133 | |
| 134 | 134 | // `newPlainText` takes two CharSequences; a String is one, and the JVM |
| 135 | 135 | // accepts the subtype without a cast. |
| @@ -270,7 +270,7 @@ fn queryUri( | ||
| 270 | 270 | for (args, 0..) |arg, i| { |
| 271 | 271 | // A null argument is a null element, which is what |
| 272 | 272 | // `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; | |
| 274 | 274 | try j.setObjectArrayElement(array, i, value); |
| 275 | 275 | } |
| 276 | 276 | break :blk array; |
| @@ -286,9 +286,9 @@ fn queryUri( | ||
| 286 | 286 | &.{ |
| 287 | 287 | .{ .l = uri }, |
| 288 | 288 | .{ .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 }, | |
| 290 | 290 | .{ .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 }, | |
| 292 | 292 | }, |
| 293 | 293 | ); |
| 294 | 294 | } |
| @@ -300,7 +300,7 @@ fn columnIndex(j: Jni, allocator: std.mem.Allocator, cursor: jobject, name: []co | ||
| 300 | 300 | return j.callIntMethodA( |
| 301 | 301 | cursor, |
| 302 | 302 | 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) }}, | |
| 304 | 304 | ); |
| 305 | 305 | } |
| 306 | 306 | |
| @@ -316,13 +316,6 @@ fn columnText( | ||
| 316 | 316 | return try j.stringToUtf8(allocator, value); |
| 317 | 317 | } |
| 318 | 318 | |
| 319 | fn 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 | ||
| 326 | 319 | // ============================================================================= |
| 327 | 320 | // addContact |
| 328 | 321 | // ============================================================================= |
| @@ -471,7 +464,7 @@ pub fn addContact(j: Jni, allocator: std.mem.Allocator, activity: jobject, conta | ||
| 471 | 464 | "(Ljava/lang/String;Ljava/util/ArrayList;)[Landroid/content/ContentProviderResult;", |
| 472 | 465 | ), |
| 473 | 466 | &.{ |
| 474 | .{ .l = try javaString(j, allocator, contacts_authority) }, | |
| 467 | .{ .l = try j.newStringUtf8(allocator, contacts_authority) }, | |
| 475 | 468 | .{ .l = ops }, |
| 476 | 469 | }, |
| 477 | 470 | ); |
| @@ -535,11 +528,11 @@ fn appendDataRow( | ||
| 535 | 528 | "withValueBackReference", |
| 536 | 529 | "(Ljava/lang/String;I)Landroid/content/ContentProviderOperation$Builder;", |
| 537 | 530 | ), |
| 538 | &.{ .{ .l = try javaString(j, allocator, col_raw_contact_id) }, .{ .i = 0 } }, | |
| 531 | &.{ .{ .l = try j.newStringUtf8(allocator, col_raw_contact_id) }, .{ .i = 0 } }, | |
| 539 | 532 | ); |
| 540 | 533 | |
| 541 | 534 | _ = 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)); | |
| 543 | 536 | |
| 544 | 537 | if (row.type_value) |type_value| { |
| 545 | 538 | const integer_cls = try j.findClass("java/lang/Integer"); |
| @@ -581,7 +574,7 @@ fn withValue( | ||
| 581 | 574 | "withValue", |
| 582 | 575 | "(Ljava/lang/String;Ljava/lang/Object;)Landroid/content/ContentProviderOperation$Builder;", |
| 583 | 576 | ), |
| 584 | &.{ .{ .l = try javaString(j, allocator, column) }, .{ .l = value } }, | |
| 577 | &.{ .{ .l = try j.newStringUtf8(allocator, column) }, .{ .l = value } }, | |
| 585 | 578 | ); |
| 586 | 579 | } |
| 587 | 580 | |
| @@ -136,7 +136,7 @@ pub fn execute(j: Jni, allocator: std.mem.Allocator, db: jobject, sql: []const u | ||
| 136 | 136 | try j.callVoidMethodA( |
| 137 | 137 | db, |
| 138 | 138 | 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 } }, | |
| 140 | 140 | ); |
| 141 | 141 | } |
| 142 | 142 | |
| @@ -161,7 +161,7 @@ pub fn query( | ||
| 161 | 161 | "rawQuery", |
| 162 | 162 | "(Ljava/lang/String;[Ljava/lang/String;)Landroid/database/Cursor;", |
| 163 | 163 | ), |
| 164 | &.{ .{ .l = try javaString(j, allocator, sql) }, .{ .l = bind } }, | |
| 164 | &.{ .{ .l = try j.newStringUtf8(allocator, sql) }, .{ .l = bind } }, | |
| 165 | 165 | ); |
| 166 | 166 | |
| 167 | 167 | try out.append(allocator, '['); |
| @@ -212,20 +212,13 @@ pub fn query( | ||
| 212 | 212 | } |
| 213 | 213 | } |
| 214 | 214 | |
| 215 | fn 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 | ||
| 222 | 215 | fn stringArray(j: Jni, allocator: std.mem.Allocator, values: []const []const u8) !jobject { |
| 223 | 216 | const string_cls = try j.findClass("java/lang/String"); |
| 224 | 217 | const array = try j.newObjectArray(values.len, string_cls); |
| 225 | 218 | for (values, 0..) |value, i| { |
| 226 | 219 | try j.pushLocalFrame(4); |
| 227 | 220 | 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)); | |
| 229 | 222 | } |
| 230 | 223 | return array; |
| 231 | 224 | } |
| @@ -66,12 +66,12 @@ fn startActivity(j: Jni, activity: jobject, intent: jobject) !void { | ||
| 66 | 66 | /// produces a Uri with a null scheme for nonsense. The failure that matters |
| 67 | 67 | /// comes later, from `startActivity` finding nothing to handle the result, and |
| 68 | 68 | /// that arrives here as `JavaException` from the `check` after the call. |
| 69 | pub fn openUrl(j: Jni, activity: jobject, url: [*:0]const u8) !void { | |
| 69 | pub fn openUrl(j: Jni, allocator: std.mem.Allocator, activity: jobject, url: []const u8) !void { | |
| 70 | 70 | try j.pushLocalFrame(16); |
| 71 | 71 | defer _ = j.popLocalFrame(null); |
| 72 | 72 | |
| 73 | 73 | 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); | |
| 75 | 75 | const uri = try j.callStaticObjectMethodA( |
| 76 | 76 | uri_cls, |
| 77 | 77 | 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 { | ||
| 93 | 93 | /// |
| 94 | 94 | /// An empty `title` skips `EXTRA_SUBJECT` entirely rather than setting it to |
| 95 | 95 | /// the empty string — see the module comment. |
| 96 | pub fn share(j: Jni, activity: jobject, text: [*:0]const u8, title: [*:0]const u8) !void { | |
| 96 | pub fn share(j: Jni, allocator: std.mem.Allocator, activity: jobject, text: []const u8, title: []const u8) !void { | |
| 97 | 97 | try j.pushLocalFrame(24); |
| 98 | 98 | defer _ = j.popLocalFrame(null); |
| 99 | 99 | |
| @@ -122,12 +122,12 @@ pub fn share(j: Jni, activity: jobject, text: [*:0]const u8, title: [*:0]const u | ||
| 122 | 122 | ); |
| 123 | 123 | |
| 124 | 124 | 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); | |
| 126 | 126 | _ = try j.callObjectMethodA(intent, put_extra, &.{ .{ .l = extra_text }, .{ .l = body } }); |
| 127 | 127 | |
| 128 | if (title[0] != 0) { | |
| 128 | if (title.len != 0) { | |
| 129 | 129 | 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); | |
| 131 | 131 | _ = try j.callObjectMethodA(intent, put_extra, &.{ .{ .l = extra_subject }, .{ .l = subject } }); |
| 132 | 132 | } |
| 133 | 133 | |
| @@ -188,7 +188,12 @@ fn fStaticObjectField(_: jni.JNIEnv, _: jni.jclass, _: jni.jfieldID) callconv(.c | ||
| 188 | 188 | return obj(2); |
| 189 | 189 | } |
| 190 | 190 | fn 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); | |
| 192 | 197 | return obj(3); |
| 193 | 198 | } |
| 194 | 199 | fn fCallStaticObjectMethodA(_: jni.JNIEnv, _: jni.jclass, id: jni.jmethodID, _: [*]const jni.jvalue) callconv(.c) jobject { |
| @@ -246,6 +251,7 @@ fn fakeEnv(table: *jni.JNINativeInterface) void { | ||
| 246 | 251 | fn resetFakes() void { |
| 247 | 252 | fake_calls.clearRetainingCapacity(); |
| 248 | 253 | fake_field_names.clearRetainingCapacity(); |
| 254 | for (fake_strings.items) |text| testing.allocator.free(text); | |
| 249 | 255 | fake_strings.clearRetainingCapacity(); |
| 250 | 256 | fake_throw_on_start = false; |
| 251 | 257 | fake_pending = null; |
| @@ -254,6 +260,7 @@ fn resetFakes() void { | ||
| 254 | 260 | fn freeFakes() void { |
| 255 | 261 | fake_calls.deinit(testing.allocator); |
| 256 | 262 | fake_field_names.deinit(testing.allocator); |
| 263 | for (fake_strings.items) |text| testing.allocator.free(text); | |
| 257 | 264 | fake_strings.deinit(testing.allocator); |
| 258 | 265 | fake_calls = .empty; |
| 259 | 266 | fake_field_names = .empty; |
| @@ -280,7 +287,7 @@ test "openURL parses the url and hands the intent to the activity" { | ||
| 280 | 287 | fakeEnv(&table); |
| 281 | 288 | const ptr: *const jni.JNINativeInterface = &table; |
| 282 | 289 | |
| 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"); | |
| 284 | 291 | |
| 285 | 292 | try testing.expect(sawCall("parse")); |
| 286 | 293 | try testing.expect(sawCall("<init>")); |
| @@ -303,7 +310,7 @@ test "a URL nothing can open reports the exception rather than success" { | ||
| 303 | 310 | fake_throw_on_start = true; |
| 304 | 311 | try testing.expectError( |
| 305 | 312 | 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"), | |
| 307 | 314 | ); |
| 308 | 315 | } |
| 309 | 316 | |
| @@ -314,7 +321,7 @@ test "share sets the text extra and the chooser" { | ||
| 314 | 321 | fakeEnv(&table); |
| 315 | 322 | const ptr: *const jni.JNINativeInterface = &table; |
| 316 | 323 | |
| 317 | try share(Jni.init(&ptr), obj(8), "hello", "Subject"); | |
| 324 | try share(Jni.init(&ptr), testing.allocator, obj(8), "hello", "Subject"); | |
| 318 | 325 | |
| 319 | 326 | try testing.expect(sawField("ACTION_SEND")); |
| 320 | 327 | try testing.expect(sawField("EXTRA_TEXT")); |
| @@ -337,7 +344,7 @@ test "an empty title omits the subject rather than setting it empty" { | ||
| 337 | 344 | fakeEnv(&table); |
| 338 | 345 | const ptr: *const jni.JNINativeInterface = &table; |
| 339 | 346 | |
| 340 | try share(Jni.init(&ptr), obj(8), "hello", ""); | |
| 347 | try share(Jni.init(&ptr), testing.allocator, obj(8), "hello", ""); | |
| 341 | 348 | |
| 342 | 349 | try testing.expect(sawField("EXTRA_TEXT")); |
| 343 | 350 | try testing.expect(!sawField("EXTRA_SUBJECT")); |
| @@ -78,7 +78,7 @@ fn apply(j: Jni, edit: jobject) !void { | ||
| 78 | 78 | try j.callVoidMethodA(edit, try j.methodId(edit_cls, "apply", "()V"), &.{}); |
| 79 | 79 | } |
| 80 | 80 | |
| 81 | pub fn set(j: Jni, prefs: jobject, key: [*:0]const u8, value: [*:0]const u8) !void { | |
| 81 | pub fn set(j: Jni, allocator: std.mem.Allocator, prefs: jobject, key: []const u8, value: []const u8) !void { | |
| 82 | 82 | try j.pushLocalFrame(16); |
| 83 | 83 | defer _ = j.popLocalFrame(null); |
| 84 | 84 | |
| @@ -92,12 +92,12 @@ pub fn set(j: Jni, prefs: jobject, key: [*:0]const u8, value: [*:0]const u8) !vo | ||
| 92 | 92 | "putString", |
| 93 | 93 | "(Ljava/lang/String;Ljava/lang/String;)Landroid/content/SharedPreferences$Editor;", |
| 94 | 94 | ), |
| 95 | &.{ .{ .l = try j.newStringUtf(key) }, .{ .l = try j.newStringUtf(value) } }, | |
| 95 | &.{ .{ .l = try j.newStringUtf8(allocator, key) }, .{ .l = try j.newStringUtf8(allocator, value) } }, | |
| 96 | 96 | ); |
| 97 | 97 | try apply(j, edit); |
| 98 | 98 | } |
| 99 | 99 | |
| 100 | pub fn remove(j: Jni, prefs: jobject, key: [*:0]const u8) !void { | |
| 100 | pub fn remove(j: Jni, allocator: std.mem.Allocator, prefs: jobject, key: []const u8) !void { | |
| 101 | 101 | try j.pushLocalFrame(16); |
| 102 | 102 | defer _ = j.popLocalFrame(null); |
| 103 | 103 | |
| @@ -107,7 +107,7 @@ pub fn remove(j: Jni, prefs: jobject, key: [*:0]const u8) !void { | ||
| 107 | 107 | _ = try j.callObjectMethodA( |
| 108 | 108 | edit, |
| 109 | 109 | 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) }}, | |
| 111 | 111 | ); |
| 112 | 112 | try apply(j, edit); |
| 113 | 113 | } |
| @@ -147,7 +147,7 @@ pub fn renderRead(allocator: std.mem.Allocator, value: ?[]const u8) ![]u8 { | ||
| 147 | 147 | } |
| 148 | 148 | |
| 149 | 149 | /// `prefs.getString(key, null)`, as an owned optional. Caller frees. |
| 150 | pub fn get(allocator: std.mem.Allocator, j: Jni, prefs: jobject, key: [*:0]const u8) !?[]u8 { | |
| 150 | pub fn get(allocator: std.mem.Allocator, j: Jni, prefs: jobject, key: []const u8) !?[]u8 { | |
| 151 | 151 | try j.pushLocalFrame(16); |
| 152 | 152 | defer _ = j.popLocalFrame(null); |
| 153 | 153 | |
| @@ -161,7 +161,7 @@ pub fn get(allocator: std.mem.Allocator, j: Jni, prefs: jobject, key: [*:0]const | ||
| 161 | 161 | ), |
| 162 | 162 | // The default is null, which is what makes "absent" distinguishable |
| 163 | 163 | // from a stored empty string. |
| 164 | &.{ .{ .l = try j.newStringUtf(key) }, .{ .l = null } }, | |
| 164 | &.{ .{ .l = try j.newStringUtf8(allocator, key) }, .{ .l = null } }, | |
| 165 | 165 | ); |
| 166 | 166 | if (value == null) return null; |
| 167 | 167 | return try j.stringToUtf8(allocator, value); |
| @@ -135,13 +135,13 @@ pub fn readMemory(j: Jni) !MemoryUsage { | ||
| 135 | 135 | /// `CraftBridge` is filtering on a string, and changing it because Zig now |
| 136 | 136 | /// emits the line would break a filter that has nothing to do with this |
| 137 | 137 | /// migration. |
| 138 | pub fn writeLog(j: Jni, message: [*:0]const u8) !void { | |
| 138 | pub fn writeLog(j: Jni, allocator: std.mem.Allocator, message: []const u8) !void { | |
| 139 | 139 | try j.pushLocalFrame(8); |
| 140 | 140 | defer _ = j.popLocalFrame(null); |
| 141 | 141 | |
| 142 | 142 | const cls = try j.findClass("android/util/Log"); |
| 143 | 143 | const tag = try j.newStringUtf("CraftBridge"); |
| 144 | const text = try j.newStringUtf(message); | |
| 144 | const text = try j.newStringUtf8(allocator, message); | |
| 145 | 145 | |
| 146 | 146 | // `Log.d` returns the number of bytes written, which the Kotlin ignores |
| 147 | 147 | // and so does this. Discarding it is not the same as calling a void |
| @@ -137,7 +137,7 @@ pub fn writeUpdate(j: Jni, allocator: std.mem.Allocator, activity: jobject, upda | ||
| 137 | 137 | defer _ = j.popLocalFrame(null); |
| 138 | 138 | _ = try j.callObjectMethodA(editor, put_string, &.{ |
| 139 | 139 | .{ .l = try j.newStringUtf(field.key) }, |
| 140 | .{ .l = try javaString(j, allocator, text) }, | |
| 140 | .{ .l = try j.newStringUtf8(allocator, text) }, | |
| 141 | 141 | }); |
| 142 | 142 | } |
| 143 | 143 | } |
| @@ -156,7 +156,7 @@ pub fn broadcast(j: Jni, allocator: std.mem.Allocator, activity: jobject, action | ||
| 156 | 156 | const intent = try j.newObjectA( |
| 157 | 157 | intent_cls, |
| 158 | 158 | try j.methodId(intent_cls, "<init>", "(Ljava/lang/String;)V"), |
| 159 | &.{.{ .l = try javaString(j, allocator, action) }}, | |
| 159 | &.{.{ .l = try j.newStringUtf8(allocator, action) }}, | |
| 160 | 160 | ); |
| 161 | 161 | |
| 162 | 162 | const activity_cls = try j.objectClass(activity); |
| @@ -181,13 +181,6 @@ pub fn broadcast(j: Jni, allocator: std.mem.Allocator, activity: jobject, action | ||
| 181 | 181 | ); |
| 182 | 182 | } |
| 183 | 183 | |
| 184 | fn 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 | ||
| 191 | 184 | // ============================================================================= |
| 192 | 185 | // Tests |
| 193 | 186 | // ============================================================================= |
| @@ -477,6 +477,9 @@ pub const JniError = error{ | ||
| 477 | 477 | StringUnavailable, |
| 478 | 478 | /// The JVM handed back bytes that are not well-formed modified UTF-8. |
| 479 | 479 | 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, | |
| 480 | 483 | OutOfMemory, |
| 481 | 484 | }; |
| 482 | 485 | |
| @@ -779,14 +782,14 @@ pub const Jni = struct { | ||
| 779 | 782 | try self.check(); |
| 780 | 783 | } |
| 781 | 784 | |
| 782 | /// Make a Java `String` from UTF-8. | |
| 785 | /// Make a Java `String` from text already in *modified* UTF-8. | |
| 783 | 786 | /// |
| 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. | |
| 790 | 793 | pub fn newStringUtf(self: Self, text: [*:0]const u8) JniError!jstring { |
| 791 | 794 | const new: *const fn (JNIEnv, [*:0]const u8) callconv(.c) jstring = |
| 792 | 795 | @ptrCast(self.table().NewStringUTF orelse return JniError.NotFound); |
| @@ -795,6 +798,22 @@ pub const Jni = struct { | ||
| 795 | 798 | return str orelse JniError.OutOfMemory; |
| 796 | 799 | } |
| 797 | 800 | |
| 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 | ||
| 798 | 817 | pub fn callIntMethod(self: Self, obj: jobject, id: jmethodID) JniError!jint { |
| 799 | 818 | const call: *const fn (JNIEnv, jobject, jmethodID) callconv(.c) jint = |
| 800 | 819 | @ptrCast(self.table().CallIntMethod orelse return JniError.NotFound); |
| @@ -966,6 +985,81 @@ fn decodeThreeByte(bytes: *const [3]u8) u32 { | ||
| 966 | 985 | @as(u32, bytes[2] & 0x3F); |
| 967 | 986 | } |
| 968 | 987 | |
| 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. | |
| 1005 | pub 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. | |
| 1051 | fn 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 | ||
| 969 | 1063 | pub fn decodeModifiedUtf8(allocator: std.mem.Allocator, input: []const u8) JniError![]u8 { |
| 970 | 1064 | var out: std.ArrayListUnmanaged(u8) = .empty; |
| 971 | 1065 | errdefer out.deinit(allocator); |
| @@ -1394,3 +1488,92 @@ test "a field read that throws reports the exception, not the value" { | ||
| 1394 | 1488 | var obj: u8 = 0; |
| 1395 | 1489 | try testing.expectError(JniError.JavaException, jni.intField(@ptrCast(&obj), null)); |
| 1396 | 1490 | } |
| 1491 | ||
| 1492 | test "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 | ||
| 1523 | test "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 | ||
| 1539 | test "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 | ||
| 1556 | test "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 | ||
| 1566 | test "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 | } | |