ReviewOS

also looking at this

craft-native/craft

feat(android): the contacts pair in Zig — ratchet 79 → 77

#166
Merged chris wants to merge feat/android-contacts into main
7 files +974 -3

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/android/templates/CraftBridge.kt.templatemodified+4-0
Changes to packages/android/templates/CraftBridge.kt.template
@@ -2216,6 +2216,10 @@ class CraftBridge(
22162216
22172217 @JavascriptInterface
22182218 fun addContact(contactJson: String) {
2219 // Zig answers through the reply channel, so returning here is what
2220 // stops the promise being settled twice.
2221 if (CraftNative.addContact(activity, contactJson)) return
2222
22192223 if (ContextCompat.checkSelfPermission(activity, Manifest.permission.WRITE_CONTACTS)
22202224 != PackageManager.PERMISSION_GRANTED) {
22212225 ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.WRITE_CONTACTS), REQUEST_CONTACTS)
packages/android/templates/CraftNative.kt.templatemodified+17-0
Changes to packages/android/templates/CraftNative.kt.template
@@ -117,6 +117,7 @@ object CraftNative {
117117 private external fun nativeGetSharedItem(activity: Activity, key: String, group: String): Boolean
118118 private external fun nativeRemoveSharedItem(activity: Activity, key: String, group: String): Boolean
119119 private external fun nativeGetContacts(activity: Activity): Boolean
120 private external fun nativeAddContact(activity: Activity, contactJson: String): Boolean
120121
121122 /**
122123 * `getDeviceInfo`, or null to use the Kotlin implementation.
@@ -466,4 +467,20 @@ object CraftNative {
466467 false
467468 }
468469 }
470
471 /**
472 * Returns whether Zig took the write.
473 *
474 * A false is routine rather than exceptional: Zig serves the shape the
475 * page's own SDK sends and hands anything else back, because `org.json`
476 * coerces where a strict parser refuses.
477 */
478 fun addContact(activity: Activity, contactJson: String): Boolean {
479 if (!isAvailable) return false
480 return try {
481 nativeAddContact(activity, contactJson)
482 } catch (e: UnsatisfiedLinkError) {
483 false
484 }
485 }
469486}
packages/zig/src/android_dispatch.zigmodified+55-1
Changes to packages/zig/src/android_dispatch.zig
@@ -287,6 +287,11 @@ const natives = [_]jni.JNINativeMethod{
287287 .signature = "(Landroid/app/Activity;)Z",
288288 .fnPtr = @ptrCast(&nativeGetContacts),
289289 },
290 .{
291 .name = "nativeAddContact",
292 .signature = "(Landroid/app/Activity;Ljava/lang/String;)Z",
293 .fnPtr = @ptrCast(&nativeAddContact),
294 },
290295};
291296
292297/// How binding went. A value rather than a log line, so every path is
@@ -1033,6 +1038,55 @@ fn nativeGetContacts(
10331038 return jni.JNI_TRUE;
10341039}
10351040
1041/// `nativeAddContact(activity, contactJson)`.
1042///
1043/// Resolves with the new contact's id as a JSON *string*, because the shim
1044/// interpolated a `Long` into a quoted JavaScript string and the page has
1045/// always received text.
1046fn nativeAddContact(
1047 env: jni.JNIEnv,
1048 _: jni.jobject,
1049 activity: jni.jobject,
1050 contact_json: jni.jstring,
1051) callconv(.c) jni.jboolean {
1052 const j = Jni.init(env);
1053 var arena = std.heap.ArenaAllocator.init(backing);
1054 defer arena.deinit();
1055 const allocator = arena.allocator();
1056
1057 const granted = permissions.isGranted(j, activity, permissions.write_contacts) catch |err| {
1058 std.log.warn("craft: addContact fell through to the shim ({s})", .{@errorName(err)});
1059 return jni.JNI_FALSE;
1060 };
1061
1062 if (!granted) {
1063 permissions.request(j, activity, permissions.write_contacts, request_contacts) catch |err| {
1064 std.log.warn("craft: addContact fell through to the shim ({s})", .{@errorName(err)});
1065 return jni.JNI_FALSE;
1066 };
1067 events.settle(allocator, contacts.add_reject_global, "\"Permission denied\"") catch return jni.JNI_FALSE;
1068 return jni.JNI_TRUE;
1069 }
1070
1071 const text = j.stringToUtf8(allocator, contact_json) catch return jni.JNI_FALSE;
1072 var parsed = std.json.parseFromSlice(std.json.Value, allocator, text, .{}) catch
1073 return jni.JNI_FALSE;
1074 defer parsed.deinit();
1075
1076 const contact = contacts.parseNewContact(parsed.value) orelse return jni.JNI_FALSE;
1077
1078 const id = contacts.addContact(j, allocator, activity, contact) catch |err| {
1079 calendar.rejectOn(allocator, contacts.add_reject_global, @errorName(err)) catch {};
1080 return jni.JNI_TRUE;
1081 };
1082
1083 var buf: [24]u8 = undefined;
1084 const digits = std.fmt.bufPrint(&buf, "{d}", .{id}) catch return jni.JNI_FALSE;
1085 const payload = calendar.jsonString(allocator, digits) catch return jni.JNI_FALSE;
1086 events.settle(allocator, contacts.add_resolve_global, payload) catch return jni.JNI_FALSE;
1087 return jni.JNI_TRUE;
1088}
1089
10361090// =============================================================================
10371091// Tests
10381092// =============================================================================
@@ -1144,7 +1198,7 @@ test "the registered natives name methods the Kotlin actually declares" {
11441198 // A descriptor is checked by the JVM at registration, so a wrong one fails
11451199 // at load rather than at call — but only if the *name* matches something.
11461200 // These two strings are the contract with CraftBridge.kt.
1147 try testing.expectEqual(@as(usize, 25), natives.len);
1201 try testing.expectEqual(@as(usize, 26), natives.len);
11481202 try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name));
11491203
11501204 // And the class they bind to is the fixed one, not the templated bridge.
packages/zig/src/android_permissions.zigmodified+2-0
Changes to packages/zig/src/android_permissions.zig
@@ -39,6 +39,7 @@ pub const granted: i32 = 0;
3939pub const read_calendar = "android.permission.READ_CALENDAR";
4040pub const write_calendar = "android.permission.WRITE_CALENDAR";
4141pub const read_contacts = "android.permission.READ_CONTACTS";
42pub const write_contacts = "android.permission.WRITE_CONTACTS";
4243
4344/// `activity.checkSelfPermission(permission) == PERMISSION_GRANTED`.
4445pub fn isGranted(j: Jni, activity: jobject, permission: [*:0]const u8) !bool {
@@ -90,6 +91,7 @@ test "the permission names match Manifest.permission exactly" {
9091 try testing.expectEqualStrings("android.permission.READ_CALENDAR", read_calendar);
9192 try testing.expectEqualStrings("android.permission.WRITE_CALENDAR", write_calendar);
9293 try testing.expectEqualStrings("android.permission.READ_CONTACTS", read_contacts);
94 try testing.expectEqualStrings("android.permission.WRITE_CONTACTS", write_contacts);
9395}
9496
9597test "PERMISSION_GRANTED is zero, and PERMISSION_DENIED is not" {
packages/zig/src/bridge_android_contacts.zigmodified+380-0
Changes to packages/zig/src/bridge_android_contacts.zig
@@ -41,10 +41,13 @@ const jobject = jni.jobject;
4141
4242pub const A = struct {
4343 pub const get_contacts = "getContacts";
44 pub const add_contact = "addContact";
4445};
4546
4647pub const resolve_global = "_craftContactsResolve";
4748pub const reject_global = "_craftContactsReject";
49pub const add_resolve_global = "_craftAddContactResolve";
50pub const add_reject_global = "_craftAddContactReject";
4851
4952const col_id = "_id";
5053const col_display_name = "display_name";
@@ -320,6 +323,279 @@ fn javaString(j: Jni, allocator: std.mem.Allocator, text: []const u8) !jni.jstri
320323 return j.newStringUtf(terminated.ptr);
321324}
322325
326// =============================================================================
327// addContact
328// =============================================================================
329//
330// ## The MIME types are read, where the column names are written
331//
332// Every constant elsewhere in this file is a literal, because a wrong column
333// name fails loudly — the provider throws or the cursor comes back empty. A
334// wrong MIMETYPE does not: `applyBatch` accepts it, the row is written, and it
335// simply never shows up as a name or a phone number anywhere. There is no
336// error and nothing to notice until someone opens the address book.
337//
338// So `CONTENT_ITEM_TYPE` is read through JNI. The value is identical to what
339// javac folded into the shim, and reading it is one static field access that
340// cannot be misremembered.
341//
342// ## What the batch builds
343//
344// A raw contact with a null account, then up to three data rows pointing back
345// at it by index rather than by id — `withValueBackReference(RAW_CONTACT_ID,
346// 0)` means "whatever the first operation inserted", which is the only way to
347// reference a row that does not exist yet.
348//
349// Each data row is skipped when its field is empty, so a contact with no phone
350// gets no phone row rather than an empty one.
351
352const col_account_type = "account_type";
353const col_account_name = "account_name";
354const col_raw_contact_id = "raw_contact_id";
355const col_mimetype = "mimetype";
356
357/// `Phone.TYPE` and `Email.TYPE`, both of which are `DATA2`.
358const col_data2 = "data2";
359
360/// `Phone.TYPE_MOBILE` and `Email.TYPE_HOME`.
361const phone_type_mobile: i32 = 2;
362const email_type_home: i32 = 1;
363
364/// `ContactsContract.AUTHORITY`.
365const contacts_authority = "com.android.contacts";
366
367/// The three optional fields the shim reads out of the payload.
368pub const NewContact = struct {
369 display_name: []const u8,
370 phone: []const u8,
371 email: []const u8,
372};
373
374/// Read the declared shape, or null where only `org.json` would.
375///
376/// The same rule `createCalendarEvent` uses and for the same reason: the shim
377/// reads these with `optString`, which coerces a number or a boolean into its
378/// string form, and `Double.toString` is not something to reproduce from
379/// memory. An explicit null is the one coercion carried over — it becomes the
380/// four characters "null", which then passes `isNotEmpty()` and is written to
381/// the address book as a contact named "null". That is what the shim does.
382pub fn parseNewContact(value: std.json.Value) ?NewContact {
383 const object = switch (value) {
384 .object => |o| o,
385 else => return null,
386 };
387
388 return .{
389 .display_name = optString(object, "displayName") orelse return null,
390 .phone = optString(object, "phone") orelse return null,
391 .email = optString(object, "email") orelse return null,
392 };
393}
394
395fn optString(object: std.json.ObjectMap, name: []const u8) ?[]const u8 {
396 const value = object.get(name) orelse return "";
397 return switch (value) {
398 .string => |text| text,
399 .null => "null",
400 else => null,
401 };
402}
403
404/// `contentResolver.applyBatch(AUTHORITY, ops)`, and the id it lands at.
405///
406/// Returns `ContentUris.parseId(results[0].uri!!)`. A batch that produced no
407/// result, or a first result with no uri, is the Kotlin's `!!` throwing — an
408/// error here, a rejection there, the same outcome for the page.
409pub fn addContact(j: Jni, allocator: std.mem.Allocator, activity: jobject, contact: NewContact) !i64 {
410 try j.pushLocalFrame(32);
411 defer _ = j.popLocalFrame(null);
412
413 const list_cls = try j.findClass("java/util/ArrayList");
414 const ops = try j.newObjectA(list_cls, try j.methodId(list_cls, "<init>", "()V"), &.{});
415 const add = try j.methodId(list_cls, "add", "(Ljava/lang/Object;)Z");
416
417 const raw_contacts_cls = try j.findClass("android/provider/ContactsContract$RawContacts");
418 const raw_uri = try j.staticObjectField(
419 raw_contacts_cls,
420 try j.staticFieldId(raw_contacts_cls, "CONTENT_URI", "Landroid/net/Uri;"),
421 );
422
423 {
424 // The raw contact itself, with a null account — a local-only contact,
425 // which is what the shim creates.
426 try j.pushLocalFrame(8);
427 defer _ = j.popLocalFrame(null);
428
429 const builder = try newInsert(j, raw_uri);
430 _ = try withValue(j, allocator, builder, col_account_type, null);
431 _ = try withValue(j, allocator, builder, col_account_name, null);
432 _ = try j.callBooleanMethodA(ops, add, &.{.{ .l = try build(j, builder) }});
433 }
434
435 const data_cls = try j.findClass("android/provider/ContactsContract$Data");
436 const data_uri = try j.staticObjectField(
437 data_cls,
438 try j.staticFieldId(data_cls, "CONTENT_URI", "Landroid/net/Uri;"),
439 );
440
441 if (contact.display_name.len != 0) {
442 try appendDataRow(j, allocator, ops, add, data_uri, "StructuredName", .{
443 .value_column = col_data1,
444 .value = contact.display_name,
445 .type_value = null,
446 });
447 }
448
449 if (contact.phone.len != 0) {
450 try appendDataRow(j, allocator, ops, add, data_uri, "Phone", .{
451 .value_column = col_data1,
452 .value = contact.phone,
453 .type_value = phone_type_mobile,
454 });
455 }
456
457 if (contact.email.len != 0) {
458 try appendDataRow(j, allocator, ops, add, data_uri, "Email", .{
459 .value_column = col_data1,
460 .value = contact.email,
461 .type_value = email_type_home,
462 });
463 }
464
465 const resolver = try contentResolver(j, activity);
466 const results = try j.callObjectMethodA(
467 resolver,
468 try j.methodId(
469 try j.objectClass(resolver),
470 "applyBatch",
471 "(Ljava/lang/String;Ljava/util/ArrayList;)[Landroid/content/ContentProviderResult;",
472 ),
473 &.{
474 .{ .l = try javaString(j, allocator, contacts_authority) },
475 .{ .l = ops },
476 },
477 );
478
479 if (results == null) return error.BatchProducedNothing;
480 if (try j.arrayLength(results) == 0) return error.BatchProducedNothing;
481
482 const first = try j.objectArrayElement(results, 0);
483 if (first == null) return error.BatchProducedNothing;
484
485 // `ContentProviderResult.uri` is a public field, not a getter.
486 const uri = try j.objectField(
487 first,
488 try j.fieldId(try j.objectClass(first), "uri", "Landroid/net/Uri;"),
489 );
490 if (uri == null) return error.BatchProducedNothing;
491
492 const content_uris_cls = try j.findClass("android/content/ContentUris");
493 return j.callStaticLongMethodA(
494 content_uris_cls,
495 try j.staticMethodId(content_uris_cls, "parseId", "(Landroid/net/Uri;)J"),
496 &.{.{ .l = uri }},
497 );
498}
499
500const DataRow = struct {
501 value_column: []const u8,
502 value: []const u8,
503 /// `Phone.TYPE` / `Email.TYPE`, absent for a structured name.
504 type_value: ?i32,
505};
506
507fn appendDataRow(
508 j: Jni,
509 allocator: std.mem.Allocator,
510 ops: jobject,
511 add: jni.jmethodID,
512 data_uri: jobject,
513 comptime kind: []const u8,
514 row: DataRow,
515) !void {
516 try j.pushLocalFrame(16);
517 defer _ = j.popLocalFrame(null);
518
519 // Comptime, so the class name is a string literal the linker holds rather
520 // than something assembled per call.
521 const kind_cls = try j.findClass("android/provider/ContactsContract$CommonDataKinds$" ++ kind);
522 const item_type = try j.staticObjectField(
523 kind_cls,
524 try j.staticFieldId(kind_cls, "CONTENT_ITEM_TYPE", "Ljava/lang/String;"),
525 );
526
527 const builder = try newInsert(j, data_uri);
528
529 // Index 0 is the raw contact this batch opened with; the row it inserts
530 // has no id until the batch runs, so it is referenced by position.
531 _ = try j.callObjectMethodA(
532 builder,
533 try j.methodId(
534 try j.objectClass(builder),
535 "withValueBackReference",
536 "(Ljava/lang/String;I)Landroid/content/ContentProviderOperation$Builder;",
537 ),
538 &.{ .{ .l = try javaString(j, allocator, col_raw_contact_id) }, .{ .i = 0 } },
539 );
540
541 _ = try withValue(j, allocator, builder, col_mimetype, item_type);
542 _ = try withValue(j, allocator, builder, row.value_column, try javaString(j, allocator, row.value));
543
544 if (row.type_value) |type_value| {
545 const integer_cls = try j.findClass("java/lang/Integer");
546 const boxed = try j.callStaticObjectMethodA(
547 integer_cls,
548 try j.staticMethodId(integer_cls, "valueOf", "(I)Ljava/lang/Integer;"),
549 &.{.{ .i = type_value }},
550 );
551 _ = try withValue(j, allocator, builder, col_data2, boxed);
552 }
553
554 _ = try j.callBooleanMethodA(ops, add, &.{.{ .l = try build(j, builder) }});
555}
556
557fn newInsert(j: Jni, uri: jobject) !jobject {
558 const op_cls = try j.findClass("android/content/ContentProviderOperation");
559 return j.callStaticObjectMethodA(
560 op_cls,
561 try j.staticMethodId(
562 op_cls,
563 "newInsert",
564 "(Landroid/net/Uri;)Landroid/content/ContentProviderOperation$Builder;",
565 ),
566 &.{.{ .l = uri }},
567 );
568}
569
570fn withValue(
571 j: Jni,
572 allocator: std.mem.Allocator,
573 builder: jobject,
574 column: []const u8,
575 value: jobject,
576) !jobject {
577 return j.callObjectMethodA(
578 builder,
579 try j.methodId(
580 try j.objectClass(builder),
581 "withValue",
582 "(Ljava/lang/String;Ljava/lang/Object;)Landroid/content/ContentProviderOperation$Builder;",
583 ),
584 &.{ .{ .l = try javaString(j, allocator, column) }, .{ .l = value } },
585 );
586}
587
588fn build(j: Jni, builder: jobject) !jobject {
589 return j.callObjectMethod(
590 builder,
591 try j.methodId(
592 try j.objectClass(builder),
593 "build",
594 "()Landroid/content/ContentProviderOperation;",
595 ),
596 );
597}
598
323599// =============================================================================
324600// Tests
325601// =============================================================================
@@ -435,3 +711,107 @@ test "getContacts names its action and globals as the shim does" {
435711 try testing.expectEqualStrings("_craftContactsResolve", resolve_global);
436712 try testing.expectEqualStrings("_craftContactsReject", reject_global);
437713}
714
715// --- addContact ------------------------------------------------------------
716
717fn parsedContact(json: []const u8) !?NewContact {
718 var arena = std.heap.ArenaAllocator.init(testing.allocator);
719 defer arena.deinit();
720
721 var parsed = try std.json.parseFromSlice(std.json.Value, arena.allocator(), json, .{});
722 defer parsed.deinit();
723
724 const contact = parseNewContact(parsed.value) orelse return null;
725 return NewContact{
726 .display_name = try testing.allocator.dupe(u8, contact.display_name),
727 .phone = try testing.allocator.dupe(u8, contact.phone),
728 .email = try testing.allocator.dupe(u8, contact.email),
729 };
730}
731
732fn freeContact(contact: NewContact) void {
733 testing.allocator.free(contact.display_name);
734 testing.allocator.free(contact.phone);
735 testing.allocator.free(contact.email);
736}
737
738test "the declared shape reads straight through" {
739 const contact = (try parsedContact(
740 \\{"displayName":"Ada","phone":"+441234567890","email":"ada@example.com"}
741 )).?;
742 defer freeContact(contact);
743
744 try testing.expectEqualStrings("Ada", contact.display_name);
745 try testing.expectEqualStrings("+441234567890", contact.phone);
746 try testing.expectEqualStrings("ada@example.com", contact.email);
747}
748
749test "absent fields are empty, and an empty field writes no row" {
750 // `optString(name, "")`, and then `if (x.isNotEmpty())` decides whether the
751 // batch gets a row at all. An empty phone is not a phone row with an empty
752 // number — it is no phone row.
753 const contact = (try parsedContact("{}")).?;
754 defer freeContact(contact);
755
756 try testing.expectEqualStrings("", contact.display_name);
757 try testing.expectEqualStrings("", contact.phone);
758 try testing.expectEqualStrings("", contact.email);
759}
760
761test "an explicit null becomes a contact named null" {
762 // Not a joke and not a rounding: `optString` on JSON null returns the four
763 // characters "null", which passes `isNotEmpty()` and is written to the
764 // address book. `JSON.stringify({displayName: null})` produces it, and the
765 // shim behaves this way today.
766 const contact = (try parsedContact(
767 \\{"displayName":null}
768 )).?;
769 defer freeContact(contact);
770
771 try testing.expectEqualStrings("null", contact.display_name);
772 try testing.expectEqualStrings("", contact.phone);
773}
774
775test "a shape only org.json would coerce is handed back" {
776 for ([_][]const u8{
777 \\{"displayName":1.5}
778 ,
779 \\{"phone":447700900000}
780 ,
781 \\{"email":true}
782 ,
783 \\[]
784 ,
785 \\"Ada"
786 ,
787 }) |payload| {
788 if (try parsedContact(payload)) |contact| {
789 freeContact(contact);
790 std.debug.print("payload was served rather than handed back: {s}\n", .{payload});
791 return error.CoercedShapeAccepted;
792 }
793 }
794}
795
796test "the batch's column names and types are the shim's" {
797 try testing.expectEqualStrings("account_type", col_account_type);
798 try testing.expectEqualStrings("account_name", col_account_name);
799 try testing.expectEqualStrings("raw_contact_id", col_raw_contact_id);
800 try testing.expectEqualStrings("mimetype", col_mimetype);
801 try testing.expectEqualStrings("com.android.contacts", contacts_authority);
802
803 // Phone.TYPE and Email.TYPE are both DATA2, the same way NUMBER and
804 // ADDRESS are both DATA1.
805 try testing.expectEqualStrings("data2", col_data2);
806
807 // Phone.TYPE_MOBILE and Email.TYPE_HOME. Different numbers in different
808 // enumerations that happen to sit in the same column.
809 try testing.expectEqual(@as(i32, 2), phone_type_mobile);
810 try testing.expectEqual(@as(i32, 1), email_type_home);
811}
812
813test "addContact names its action and globals as the shim does" {
814 try testing.expectEqualStrings("addContact", A.add_contact);
815 try testing.expectEqualStrings("_craftAddContactResolve", add_resolve_global);
816 try testing.expectEqualStrings("_craftAddContactReject", add_reject_global);
817}
packages/zig/test/android_conformance_test.zigmodified+2-2
Changes to packages/zig/test/android_conformance_test.zig
@@ -70,8 +70,8 @@ const zig_sources = [_][]const u8{
7070/// getCalendarEvents, the first to send the page a payload it built rather
7171/// than a constant; 84 with createCalendarEvent, the first to read one; 82
7272/// with the database pair, the first to borrow a connection the Kotlin owns;
73/// 79 with the shared-item trio; 78 with getContacts.
74const max_not_yet_migrated: usize = 78;
73/// 79 with the shared-item trio; 77 with the contacts pair.
74const max_not_yet_migrated: usize = 77;
7575
7676/// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge.
7777///