ReviewOS

also looking at this

craft-native/craft

test(android): compare each JNI descriptor with its Kotlin external fun

#235
Merged glennmichael123 wants to merge test/android-jni-descriptors into main
1 file +192 -3
packages/zig/test/android_conformance_test.zigmodified+192-3
Changes to packages/zig/test/android_conformance_test.zig
@@ -18,13 +18,17 @@
1818//!
1919//! 1. **the action**, `@JavascriptInterface fun getDeviceInfo()` against the
2020//! `A` block of whichever Zig module serves it;
21//! 2. **the binding**, the `name` in `android_dispatch.natives` against the
22//! `external fun` that `CraftNative.kt` declares.
21//! 2. **the binding**, the `name` *and the JNI descriptor* in
22//! `android_dispatch.natives` against the `external fun` that
23//! `CraftNative.kt` declares.
2324//!
2425//! The second has no compiler behind it at all. `RegisterNatives` matches by
2526//! string at load, so renaming one side and not the other fails at
2627//! `System.loadLibrary` — in the app, on a device, with a message that names
27//! the method and nothing about where the other half went.
28//! the method and nothing about where the other half went. A descriptor that
29//! disagrees is quieter still: the batch is refused, every action falls back
30//! to Kotlin, and only the runtime E2E leg notices, as a missing
31//! `registered N natives` line after a full emulator run (#230).
2832
2933const std = ;
3034const testing = std.testing;
@@ -567,6 +571,191 @@ test "every native the Kotlin holder declares is one Zig registers" {
567571 }
568572}
569573
574/// The `signature` of every entry in `android_dispatch.natives`, by name.
575fn collectRegisteredDescriptors(allocator: std.mem.Allocator) !std.StringHashMap([]const u8) {
576 var map = std.StringHashMap([]const u8).init(allocator);
577 errdefer map.deinit();
578
579 const table_start = std.mem.indexOf(u8, dispatch_source, "const natives = [_]jni.JNINativeMethod{") orelse
580 return error.NativesTableNotFound;
581 const table_end = std.mem.indexOfPos(u8, dispatch_source, table_start, "\n};") orelse
582 return error.NativesTableNotFound;
583 const table = dispatch_source[table_start..table_end];
584
585 const name_needle = ".name = \"";
586 const signature_needle = ".signature = \"";
587 var search: usize = 0;
588 while (std.mem.indexOfPos(u8, table, search, name_needle)) |at| {
589 const name_start = at + name_needle.len;
590 const name_end = std.mem.indexOfScalarPos(u8, table, name_start, '"') orelse break;
591 // The signature of this entry, which is the next one after its name.
592 const signature_at = std.mem.indexOfPos(u8, table, name_end, signature_needle) orelse
593 return error.EntryWithoutSignature;
594 const signature_start = signature_at + signature_needle.len;
595 const signature_end = std.mem.indexOfScalarPos(u8, table, signature_start, '"') orelse
596 return error.EntryWithoutSignature;
597 try map.put(table[name_start..name_end], table[signature_start..signature_end]);
598 search = signature_end;
599 }
600 return map;
601}
602
603/// The descriptor for a Kotlin type the holder writes without importing it.
604fn builtinDescriptor(kotlin_type: []const u8) ?[]const u8 {
605 const table = .{
606 .{ "Boolean", "Z" },
607 .{ "Int", "I" },
608 .{ "Long", "J" },
609 .{ "Float", "F" },
610 .{ "Double", "D" },
611 .{ "ByteArray", "[B" },
612 .{ "String", "Ljava/lang/String;" },
613 .{ "Unit", "V" },
614 };
615 inline for (table) |row| {
616 if (std.mem.eql(u8, kotlin_type, row[0])) return row[1];
617 }
618 return null;
619}
620
621/// `Landroid/app/Activity;` for `Activity`, read from the holder's own
622/// imports, so a class this file starts using needs no table here.
623fn importedDescriptor(allocator: std.mem.Allocator, simple: []const u8) !?[]u8 {
624 var lines = std.mem.splitScalar(u8, native_holder, '\n');
625 while (lines.next()) |line| {
626 const trimmed = std.mem.trim(u8, line, " \t\r");
627 if (!std.mem.startsWith(u8, trimmed, "import ")) continue;
628 const path = std.mem.trim(u8, trimmed["import ".len..], " \t\r");
629 // `import com.android.billingclient.api.*` names no class.
630 const dot = std.mem.lastIndexOfScalar(u8, path, '.') orelse continue;
631 if (!std.mem.eql(u8, path[dot + 1 ..], simple)) continue;
632
633 const descriptor = try allocator.alloc(u8, path.len + 2);
634 descriptor[0] = 'L';
635 @memcpy(descriptor[1 .. path.len + 1], path);
636 descriptor[path.len + 1] = ';';
637 for (descriptor) |*character| {
638 if (character.* == '.') character.* = '/';
639 }
640 return descriptor;
641 }
642 return null;
643}
644
645/// One `external fun`'s descriptor, or an error naming the type that stopped it.
646fn descriptorFor(allocator: std.mem.Allocator, kotlin_type: []const u8) ![]u8 {
647 // `String?` and `String` are one descriptor: nullability is Kotlin's, not
648 // the JVM's.
649 const bare = if (std.mem.endsWith(u8, kotlin_type, "?"))
650 kotlin_type[0 .. kotlin_type.len - 1]
651 else
652 kotlin_type;
653 if (builtinDescriptor(bare)) |descriptor| return allocator.dupe(u8, descriptor);
654 if (try importedDescriptor(allocator, bare)) |descriptor| return descriptor;
655 std.debug.print(
656 "CraftNative.kt uses the type '{s}', which this test cannot turn into a\n" ++
657 " JNI descriptor: it is neither a Kotlin builtin nor a class the file imports.\n" ++
658 " Add it to builtinDescriptor, or import it in CraftNative.kt.\n",
659 .{bare},
660 );
661 return error.UnknownKotlinType;
662}
663
664/// Every `external fun` in the holder, as the descriptor JNI expects for it.
665fn collectDeclaredDescriptors(allocator: std.mem.Allocator) !std.StringHashMap([]u8) {
666 var map = std.StringHashMap([]u8).init(allocator);
667 errdefer {
668 var stale = map.valueIterator();
669 while (stale.next()) |descriptor| allocator.free(descriptor.*);
670 map.deinit();
671 }
672
673 const needle = "external fun ";
674 var search: usize = 0;
675 while (std.mem.indexOfPos(u8, native_holder, search, needle)) |at| {
676 const name_start = at + needle.len;
677 var name_end = name_start;
678 while (name_end < native_holder.len and
679 (std.ascii.isAlphanumeric(native_holder[name_end]) or native_holder[name_end] == '_')) : (name_end += 1)
680 {}
681 if (name_end == name_start or name_end >= native_holder.len or native_holder[name_end] != '(') {
682 search = name_end;
683 continue;
684 }
685 const params_end = std.mem.indexOfScalarPos(u8, native_holder, name_end, ')') orelse
686 return error.DeclarationNotClosed;
687 search = params_end;
688
689 var descriptor: std.ArrayListUnmanaged(u8) = .empty;
690 errdefer descriptor.deinit(allocator);
691 try descriptor.append(allocator, '(');
692
693 // `name: Type` pairs, across lines for the declarations that wrap.
694 var params = std.mem.splitScalar(u8, native_holder[name_end + 1 .. params_end], ',');
695 while (params.next()) |param| {
696 const colon = std.mem.indexOfScalar(u8, param, ':') orelse continue;
697 const written = try descriptorFor(allocator, std.mem.trim(u8, param[colon + 1 ..], " \t\r\n"));
698 defer allocator.free(written);
699 try descriptor.appendSlice(allocator, written);
700 }
701 try descriptor.append(allocator, ')');
702
703 // `): Type`, or nothing at all, which is Unit.
704 var after = params_end + 1;
705 while (after < native_holder.len and (native_holder[after] == ' ' or native_holder[after] == '\t')) : (after += 1) {}
706 var returns: []const u8 = "Unit";
707 if (after < native_holder.len and native_holder[after] == ':') {
708 var type_start = after + 1;
709 while (type_start < native_holder.len and native_holder[type_start] == ' ') : (type_start += 1) {}
710 var type_end = type_start;
711 while (type_end < native_holder.len and
712 (std.ascii.isAlphanumeric(native_holder[type_end]) or native_holder[type_end] == '?')) : (type_end += 1)
713 {}
714 returns = native_holder[type_start..type_end];
715 }
716 const written = try descriptorFor(allocator, returns);
717 defer allocator.free(written);
718 try descriptor.appendSlice(allocator, written);
719
720 try map.put(native_holder[name_start..name_end], try descriptor.toOwnedSlice(allocator));
721 }
722 return map;
723}
724
725test "every registered descriptor is the one its external fun declares" {
726 // The quietest of the three. A descriptor that disagrees with its Kotlin
727 // makes RegisterNatives refuse the whole table, so the library loads, no
728 // native binds, and every action falls back to the Kotlin shim: the shim
729 // leg of the E2E suite still passes, and only the runtime leg notices.
730 // #215 changed one descriptor and could only pin the string itself.
731 var registered = try collectRegisteredDescriptors(testing.allocator);
732 defer registered.deinit();
733 var declared = try collectDeclaredDescriptors(testing.allocator);
734 defer {
735 var descriptors = declared.valueIterator();
736 while (descriptors.next()) |descriptor| testing.allocator.free(descriptor.*);
737 declared.deinit();
738 }
739
740 // Non-vacuity: both scans read a file that could stop matching.
741 try testing.expect(registered.count() >= 100);
742 try testing.expect(declared.count() >= 100);
743
744 var it = registered.iterator();
745 while (it.next()) |entry| {
746 const expected = declared.get(entry.key_ptr.*) orelse continue; // the name tests own this
747 if (!std.mem.eql(u8, expected, entry.value_ptr.*)) {
748 std.debug.print(
749 "android_dispatch registers '{s}' as\n {s}\n" ++
750 " but CraftNative.kt declares it as\n {s}\n" ++
751 " RegisterNatives refuses the whole table, and every action stays on the shim.\n",
752 .{ entry.key_ptr.*, entry.value_ptr.*, expected },
753 );
754 return error.DescriptorDoesNotMatchKotlin;
755 }
756 }
757}
758
570759test "the holder's package is the one JNI_OnLoad looks for" {
571760 // The third string with no compiler behind it. `FindClass` takes a binary
572761 // name with slashes; the Kotlin declares a package with dots. They are