also looking at this
test(android): compare each JNI descriptor with its Kotlin external fun
#235
1 file
+192
-3
| @@ -18,13 +18,17 @@ | ||
| 18 | 18 | //! |
| 19 | 19 | //! 1. **the action**, `@JavascriptInterface fun getDeviceInfo()` against the |
| 20 | 20 | //! `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. | |
| 23 | 24 | //! |
| 24 | 25 | //! The second has no compiler behind it at all. `RegisterNatives` matches by |
| 25 | 26 | //! string at load, so renaming one side and not the other fails at |
| 26 | 27 | //! `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). | |
| 28 | 32 | |
| 29 | 33 | const std = ; |
| 30 | 34 | const testing = std.testing; |
| @@ -567,6 +571,191 @@ test "every native the Kotlin holder declares is one Zig registers" { | ||
| 567 | 571 | } |
| 568 | 572 | } |
| 569 | 573 | |
| 574 | /// The `signature` of every entry in `android_dispatch.natives`, by name. | |
| 575 | fn 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. | |
| 604 | fn 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. | |
| 623 | fn 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. | |
| 646 | fn 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. | |
| 665 | fn 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 | ||
| 725 | test "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 | ||
| 570 | 759 | test "the holder's package is the one JNI_OnLoad looks for" { |
| 571 | 760 | // The third string with no compiler behind it. `FindClass` takes a binary |
| 572 | 761 | // name with slashes; the Kotlin declares a package with dots. They are |