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
packages/android/templates/CraftBridge.kt.templatemodified+8-0
Changes to packages/android/templates/CraftBridge.kt.template
@@ -2141,6 +2141,10 @@ class CraftBridge(
21412141
21422142 @JavascriptInterface
21432143 fun getContacts() {
2144 // Zig answers through the reply channel, so returning here is what
2145 // stops the promise being settled twice.
2146 if (CraftNative.getContacts(activity)) return
2147
21442148 if (ContextCompat.checkSelfPermission(activity, Manifest.permission.READ_CONTACTS)
21452149 != PackageManager.PERMISSION_GRANTED) {
21462150 ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.READ_CONTACTS), REQUEST_CONTACTS)
@@ -2212,6 +2216,10 @@ class CraftBridge(
22122216
22132217 @JavascriptInterface
22142218 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
22152223 if (ContextCompat.checkSelfPermission(activity, Manifest.permission.WRITE_CONTACTS)
22162224 != PackageManager.PERMISSION_GRANTED) {
22172225 ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.WRITE_CONTACTS), REQUEST_CONTACTS)
packages/android/templates/CraftNative.kt.templatemodified+33-0
Changes to packages/android/templates/CraftNative.kt.template
@@ -116,6 +116,8 @@ object CraftNative {
116116 private external fun nativeSetSharedItem(activity: Activity, key: String, value: String, group: String): Boolean
117117 private external fun nativeGetSharedItem(activity: Activity, key: String, group: String): Boolean
118118 private external fun nativeRemoveSharedItem(activity: Activity, key: String, group: String): Boolean
119 private external fun nativeGetContacts(activity: Activity): Boolean
120 private external fun nativeAddContact(activity: Activity, contactJson: String): Boolean
119121
120122 /**
121123 * `getDeviceInfo`, or null to use the Kotlin implementation.
@@ -450,4 +452,35 @@ object CraftNative {
450452 false
451453 }
452454 }
455
456 /**
457 * Returns whether Zig took the read, not whether it found anyone.
458 *
459 * An empty address book still answers — with `[]`, through the reply
460 * channel — so a false here means Zig did not run.
461 */
462 fun getContacts(activity: Activity): Boolean {
463 if (!isAvailable) return false
464 return try {
465 nativeGetContacts(activity)
466 } catch (e: UnsatisfiedLinkError) {
467 false
468 }
469 }
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 }
453486}
packages/zig/build.zigmodified+3-0
Changes to packages/zig/build.zig
@@ -464,6 +464,9 @@ pub fn build(b: *std.Build) void {
464464 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_shareditem.zig", .{
465465 .root_source_file = b.path("src/bridge_android_shareditem.zig"),
466466 });
467 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_contacts.zig", .{
468 .root_source_file = b.path("src/bridge_android_contacts.zig"),
469 });
467470 const run_android_conformance_tests = b.addRunArtifact(android_conformance_tests);
468471
469472 // The page surface, across both bridges. Not folded into the iOS
packages/zig/src/android_dispatch.zigmodified+106-1
Changes to packages/zig/src/android_dispatch.zig
@@ -49,6 +49,7 @@ const events = ;
4949const calendar = ;
5050const db = ;
5151const shareditem = ;
52const contacts = ;
5253
5354const Jni = jni.Jni;
5455
@@ -77,6 +78,9 @@ const request_calendar: i32 = 1005;
7778/// The shim's default event length: `System.currentTimeMillis() + 3600000`.
7879const one_hour_ms: i64 = 60 * 60 * 1000;
7980
81/// `CraftBridge.REQUEST_CONTACTS`.
82const request_contacts: i32 = 1004;
83
8084/// The VM, captured at load so a later callback on a framework thread can ask
8185/// it for that thread's `JNIEnv`.
8286///
@@ -278,6 +282,16 @@ const natives = [_]jni.JNINativeMethod{
278282 .signature = "(Landroid/app/Activity;Ljava/lang/String;Ljava/lang/String;)Z",
279283 .fnPtr = @ptrCast(&nativeRemoveSharedItem),
280284 },
285 .{
286 .name = "nativeGetContacts",
287 .signature = "(Landroid/app/Activity;)Z",
288 .fnPtr = @ptrCast(&nativeGetContacts),
289 },
290 .{
291 .name = "nativeAddContact",
292 .signature = "(Landroid/app/Activity;Ljava/lang/String;)Z",
293 .fnPtr = @ptrCast(&nativeAddContact),
294 },
281295};
282296
283297/// How binding went. A value rather than a log line, so every path is
@@ -982,6 +996,97 @@ fn nativeRemoveSharedItem(
982996 return jni.JNI_TRUE;
983997}
984998
999/// `nativeGetContacts(activity)`.
1000///
1001/// Falls through when the query fails, for the reason `getCalendarEvents`
1002/// does: the shim wraps none of `getContacts` in a try/catch, so an exception
1003/// escapes the `@JavascriptInterface` method and the promise hangs. Inventing
1004/// a rejection here would be kinder and would be a divergence nothing
1005/// records. See #157.
1006fn nativeGetContacts(
1007 env: jni.JNIEnv,
1008 _: jni.jobject,
1009 activity: jni.jobject,
1010) callconv(.c) jni.jboolean {
1011 const j = Jni.init(env);
1012 var arena = std.heap.ArenaAllocator.init(backing);
1013 defer arena.deinit();
1014 const allocator = arena.allocator();
1015
1016 const granted = permissions.isGranted(j, activity, permissions.read_contacts) catch |err| {
1017 std.log.warn("craft: getContacts fell through to the shim ({s})", .{@errorName(err)});
1018 return jni.JNI_FALSE;
1019 };
1020
1021 if (!granted) {
1022 permissions.request(j, activity, permissions.read_contacts, request_contacts) catch |err| {
1023 std.log.warn("craft: getContacts fell through to the shim ({s})", .{@errorName(err)});
1024 return jni.JNI_FALSE;
1025 };
1026 events.settle(allocator, contacts.reject_global, "\"Permission denied\"") catch return jni.JNI_FALSE;
1027 return jni.JNI_TRUE;
1028 }
1029
1030 var payload: std.ArrayListUnmanaged(u8) = .empty;
1031 defer payload.deinit(allocator);
1032 contacts.queryContacts(j, allocator, activity, &payload) catch |err| {
1033 std.log.warn("craft: getContacts fell through to the shim ({s})", .{@errorName(err)});
1034 return jni.JNI_FALSE;
1035 };
1036
1037 events.settle(allocator, contacts.resolve_global, payload.items) catch return jni.JNI_FALSE;
1038 return jni.JNI_TRUE;
1039}
1040
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
9851090// =============================================================================
9861091// Tests
9871092// =============================================================================
@@ -1093,7 +1198,7 @@ test "the registered natives name methods the Kotlin actually declares" {
10931198 // A descriptor is checked by the JVM at registration, so a wrong one fails
10941199 // at load rather than at call — but only if the *name* matches something.
10951200 // These two strings are the contract with CraftBridge.kt.
1096 try testing.expectEqual(@as(usize, 24), natives.len);
1201 try testing.expectEqual(@as(usize, 26), natives.len);
10971202 try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name));
10981203
10991204 // And the class they bind to is the fixed one, not the templated bridge.
packages/zig/src/android_permissions.zigmodified+4-0
Changes to packages/zig/src/android_permissions.zig
@@ -38,6 +38,8 @@ pub const granted: i32 = 0;
3838
3939pub const read_calendar = "android.permission.READ_CALENDAR";
4040pub const write_calendar = "android.permission.WRITE_CALENDAR";
41pub const read_contacts = "android.permission.READ_CONTACTS";
42pub const write_contacts = "android.permission.WRITE_CONTACTS";
4143
4244/// `activity.checkSelfPermission(permission) == PERMISSION_GRANTED`.
4345pub fn isGranted(j: Jni, activity: jobject, permission: [*:0]const u8) !bool {
@@ -88,6 +90,8 @@ test "the permission names match Manifest.permission exactly" {
8890 // that is never granted and an action that always rejects.
8991 try testing.expectEqualStrings("android.permission.READ_CALENDAR", read_calendar);
9092 try testing.expectEqualStrings("android.permission.WRITE_CALENDAR", write_calendar);
93 try testing.expectEqualStrings("android.permission.READ_CONTACTS", read_contacts);
94 try testing.expectEqualStrings("android.permission.WRITE_CONTACTS", write_contacts);
9195}
9296
9397test "PERMISSION_GRANTED is zero, and PERMISSION_DENIED is not" {
packages/zig/src/bridge_android_contacts.zigadded+817-0
Changes to packages/zig/src/bridge_android_contacts.zig
@@ -0,0 +1,817 @@
1//! `getContacts` on Android.
2//!
3//! ## The column names are literals, and that is the faithful choice
4//!
5//! `ContactsContract.Contacts.DISPLAY_NAME` and its neighbours are
6//! `public static final String` — compile-time constants, so javac folds them
7//! into the shim's DEX and the shim never reads those fields at runtime
8//! either. The `CONTENT_URI`s are `Uri` objects and so are still read through
9//! JNI, the same split the calendar module makes.
10//!
11//! Two of them are the same string and it is not a mistake: `Phone.NUMBER` and
12//! `Email.ADDRESS` are both `DATA1`, because a data row's meaning comes from
13//! its MIMETYPE and the generic `data1` column holds whichever value that row
14//! is. Writing "data1" twice is what the shim compiles to.
15//!
16//! ## One query per contact, twice over
17//!
18//! The shim runs a query for the contact list and then two more *per contact*,
19//! one for phones and one for emails, each on the JavaBridge thread. A device
20//! with two thousand contacts makes four thousand and one queries for a single
21//! `craft.contacts.getAll()`. Reproduced rather than improved: the same rows
22//! could be fetched in three queries and grouped, but that changes what a
23//! concurrent edit to the address book produces, and this port is meant to be
24//! auditable against the Kotlin line by line. See #165.
25//!
26//! ## A null is not the same absence twice
27//!
28//! `put("id", id)` with a null **removes** the key, so a contact with no `_id`
29//! arrives without one. `put("displayName", name ?: "")` turns the same null
30//! into an empty string. And a null phone number is appended to its array as a
31//! JSON `null`, because `JSONArray.put` stores it and `JSONStringer` writes it
32//! out. Three different answers to the same missing value, all of them the
33//! shim's.
34
35const std = ;
36const jni = ;
37const bridge_error = ;
38
39const Jni = jni.Jni;
40const jobject = jni.jobject;
41
42pub const A = struct {
43 pub const get_contacts = "getContacts";
44 pub const add_contact = "addContact";
45};
46
47pub const resolve_global = "_craftContactsResolve";
48pub const reject_global = "_craftContactsReject";
49pub const add_resolve_global = "_craftAddContactResolve";
50pub const add_reject_global = "_craftAddContactReject";
51
52const col_id = "_id";
53const col_display_name = "display_name";
54
55/// `Phone.CONTACT_ID` and `Email.CONTACT_ID`, the join back to the contact.
56const col_contact_id = "contact_id";
57
58/// `Phone.NUMBER` and `Email.ADDRESS`, both of which are `DATA1`.
59const col_data1 = "data1";
60
61const contacts_sort_order = col_display_name ++ " ASC";
62const data_selection = col_contact_id ++ " = ?";
63
64/// One contact, as the two cursors give it.
65pub const Contact = struct {
66 id: ?[]const u8,
67 display_name: ?[]const u8,
68 phone_numbers: []const ?[]const u8,
69 email_addresses: []const ?[]const u8,
70};
71
72/// One contact as the shim's `JSONObject` would print it.
73pub fn appendContact(
74 allocator: std.mem.Allocator,
75 out: *std.ArrayListUnmanaged(u8),
76 contact: Contact,
77) !void {
78 try out.append(allocator, '{');
79
80 // `put("id", null)` removes the mapping, so a null id is an absent key
81 // rather than a JSON null — and the comma has to go with it.
82 if (contact.id) |id| {
83 try appendStringMember(allocator, out, "id", id);
84 try out.append(allocator, ',');
85 }
86
87 // `?: ""` — the same null, answered differently one line later.
88 try appendStringMember(allocator, out, "displayName", contact.display_name orelse "");
89
90 try out.appendSlice(allocator, ",\"phoneNumbers\":");
91 try appendStrings(allocator, out, contact.phone_numbers);
92 try out.appendSlice(allocator, ",\"emailAddresses\":");
93 try appendStrings(allocator, out, contact.email_addresses);
94
95 try out.append(allocator, '}');
96}
97
98/// A `JSONArray` of strings, where a null element is a JSON null.
99///
100/// `JSONArray.put(Object)` stores the null and `JSONStringer` writes `null`
101/// for it, so unlike a `JSONObject` member the element is not dropped — the
102/// array keeps its length and the page sees a hole.
103fn appendStrings(
104 allocator: std.mem.Allocator,
105 out: *std.ArrayListUnmanaged(u8),
106 values: []const ?[]const u8,
107) !void {
108 try out.append(allocator, '[');
109 for (values, 0..) |value, i| {
110 if (i != 0) try out.append(allocator, ',');
111 if (value) |text| {
112 try out.append(allocator, '"');
113 try bridge_error.appendJsonEscaped(allocator, out, text);
114 try out.append(allocator, '"');
115 } else {
116 try out.appendSlice(allocator, "null");
117 }
118 }
119 try out.append(allocator, ']');
120}
121
122fn appendStringMember(
123 allocator: std.mem.Allocator,
124 out: *std.ArrayListUnmanaged(u8),
125 name: []const u8,
126 value: []const u8,
127) !void {
128 try out.append(allocator, '"');
129 try out.appendSlice(allocator, name);
130 try out.appendSlice(allocator, "\":\"");
131 try bridge_error.appendJsonEscaped(allocator, out, value);
132 try out.append(allocator, '"');
133}
134
135/// The whole address book, appended to `out` as a JSON array.
136pub fn queryContacts(
137 j: Jni,
138 allocator: std.mem.Allocator,
139 activity: jobject,
140 out: *std.ArrayListUnmanaged(u8),
141) !void {
142 try j.pushLocalFrame(24);
143 defer _ = j.popLocalFrame(null);
144
145 const resolver = try contentResolver(j, activity);
146 const contacts_cls = try j.findClass("android/provider/ContactsContract$Contacts");
147 const content_uri = try j.staticObjectField(
148 contacts_cls,
149 try j.staticFieldId(contacts_cls, "CONTENT_URI", "Landroid/net/Uri;"),
150 );
151
152 // `query(uri, null, null, null, "display_name ASC")` — a null projection
153 // selects every column of a wide table, which is the shim's call and is
154 // half of why #165 exists.
155 const cursor = try queryUri(j, allocator, resolver, content_uri, null, null, contacts_sort_order);
156
157 try out.append(allocator, '[');
158 defer out.append(allocator, ']') catch {};
159
160 if (cursor == null) return;
161
162 const cursor_cls = try j.objectClass(cursor);
163 const move_to_next = try j.methodId(cursor_cls, "moveToNext", "()Z");
164 const get_string = try j.methodId(cursor_cls, "getString", "(I)Ljava/lang/String;");
165 const close = try j.methodId(cursor_cls, "close", "()V");
166 defer j.callVoidMethodA(cursor, close, &.{}) catch {};
167
168 // `getColumnIndexOrThrow` is resolved once rather than per row. The shim
169 // calls it inside the loop; the answer cannot change while a cursor is
170 // open, and the difference is not observable.
171 const id_index = try columnIndex(j, allocator, cursor, col_id);
172 const name_index = try columnIndex(j, allocator, cursor, col_display_name);
173
174 var count: usize = 0;
175 while (try j.callBooleanMethodA(cursor, move_to_next, &.{})) {
176 try j.pushLocalFrame(8);
177 defer _ = j.popLocalFrame(null);
178
179 const id = try columnText(j, allocator, cursor, get_string, id_index);
180 const name = try columnText(j, allocator, cursor, get_string, name_index);
181
182 // The two extra queries per contact. They need the id as text, and a
183 // contact without one cannot be joined to — the shim passes the null
184 // straight to `arrayOf(contactId)`, where it becomes a selection
185 // argument of null and the provider matches nothing.
186 const phones = try relatedValues(j, allocator, resolver, "Phone", id);
187 const emails = try relatedValues(j, allocator, resolver, "Email", id);
188
189 if (count != 0) try out.append(allocator, ',');
190 try appendContact(allocator, out, .{
191 .id = id,
192 .display_name = name,
193 .phone_numbers = phones,
194 .email_addresses = emails,
195 });
196 count += 1;
197 }
198}
199
200/// `getContactPhones` / `getContactEmails`, which differ only in the class
201/// holding the `CONTENT_URI`.
202fn relatedValues(
203 j: Jni,
204 allocator: std.mem.Allocator,
205 resolver: jobject,
206 comptime kind: []const u8,
207 contact_id: ?[]const u8,
208) ![]const ?[]const u8 {
209 try j.pushLocalFrame(16);
210 defer _ = j.popLocalFrame(null);
211
212 const cls = try j.findClass("android/provider/ContactsContract$CommonDataKinds$" ++ kind);
213 const content_uri = try j.staticObjectField(
214 cls,
215 try j.staticFieldId(cls, "CONTENT_URI", "Landroid/net/Uri;"),
216 );
217
218 const args = [_]?[]const u8{contact_id};
219 const cursor = try queryUri(j, allocator, resolver, content_uri, data_selection, &args, null);
220
221 var values: std.ArrayListUnmanaged(?[]const u8) = .empty;
222 errdefer values.deinit(allocator);
223
224 if (cursor == null) return values.toOwnedSlice(allocator);
225
226 const cursor_cls = try j.objectClass(cursor);
227 const move_to_next = try j.methodId(cursor_cls, "moveToNext", "()Z");
228 const get_string = try j.methodId(cursor_cls, "getString", "(I)Ljava/lang/String;");
229 const close = try j.methodId(cursor_cls, "close", "()V");
230 defer j.callVoidMethodA(cursor, close, &.{}) catch {};
231
232 const value_index = try columnIndex(j, allocator, cursor, col_data1);
233
234 while (try j.callBooleanMethodA(cursor, move_to_next, &.{})) {
235 try j.pushLocalFrame(8);
236 defer _ = j.popLocalFrame(null);
237 try values.append(allocator, try columnText(j, allocator, cursor, get_string, value_index));
238 }
239 return values.toOwnedSlice(allocator);
240}
241
242fn contentResolver(j: Jni, activity: jobject) !jobject {
243 return j.callObjectMethod(
244 activity,
245 try j.methodId(
246 try j.objectClass(activity),
247 "getContentResolver",
248 "()Landroid/content/ContentResolver;",
249 ),
250 );
251}
252
253/// `resolver.query(uri, null, selection, selectionArgs, sortOrder)`.
254///
255/// The projection is always null here because both of the shim's queries pass
256/// null; a caller wanting one would pass the array rather than this taking a
257/// parameter nothing sets.
258fn queryUri(
259 j: Jni,
260 allocator: std.mem.Allocator,
261 resolver: jobject,
262 uri: jobject,
263 selection: ?[]const u8,
264 selection_args: ?[]const ?[]const u8,
265 sort_order: ?[]const u8,
266) !jobject {
267 const args_array: jobject = if (selection_args) |args| blk: {
268 const string_cls = try j.findClass("java/lang/String");
269 const array = try j.newObjectArray(args.len, string_cls);
270 for (args, 0..) |arg, i| {
271 // A null argument is a null element, which is what
272 // `arrayOf(contactId)` produces when the id was null.
273 const value: jni.jstring = if (arg) |text| try javaString(j, allocator, text) else null;
274 try j.setObjectArrayElement(array, i, value);
275 }
276 break :blk array;
277 } else null;
278
279 return j.callObjectMethodA(
280 resolver,
281 try j.methodId(
282 try j.objectClass(resolver),
283 "query",
284 "(Landroid/net/Uri;[Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;Ljava/lang/String;)Landroid/database/Cursor;",
285 ),
286 &.{
287 .{ .l = uri },
288 .{ .l = null },
289 .{ .l = if (selection) |text| try javaString(j, allocator, text) else null },
290 .{ .l = args_array },
291 .{ .l = if (sort_order) |text| try javaString(j, allocator, text) else null },
292 },
293 );
294}
295
296fn columnIndex(j: Jni, allocator: std.mem.Allocator, cursor: jobject, name: []const u8) !i32 {
297 try j.pushLocalFrame(4);
298 defer _ = j.popLocalFrame(null);
299
300 return j.callIntMethodA(
301 cursor,
302 try j.methodId(try j.objectClass(cursor), "getColumnIndexOrThrow", "(Ljava/lang/String;)I"),
303 &.{.{ .l = try javaString(j, allocator, name) }},
304 );
305}
306
307fn columnText(
308 j: Jni,
309 allocator: std.mem.Allocator,
310 cursor: jobject,
311 get_string: jni.jmethodID,
312 index: i32,
313) !?[]const u8 {
314 const value = try j.callObjectMethodA(cursor, get_string, &.{.{ .i = index }});
315 if (value == null) return null;
316 return try j.stringToUtf8(allocator, value);
317}
318
319fn javaString(j: Jni, allocator: std.mem.Allocator, text: []const u8) !jni.jstring {
320 const terminated = try allocator.allocSentinel(u8, text.len, 0);
321 defer allocator.free(terminated);
322 @memcpy(terminated, text);
323 return j.newStringUtf(terminated.ptr);
324}
325
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
599// =============================================================================
600// Tests
601// =============================================================================
602
603const testing = std.testing;
604
605fn renderContacts(contacts: []const Contact) ![]u8 {
606 var out: std.ArrayListUnmanaged(u8) = .empty;
607 errdefer out.deinit(testing.allocator);
608 try out.append(testing.allocator, '[');
609 for (contacts, 0..) |contact, i| {
610 if (i != 0) try out.append(testing.allocator, ',');
611 try appendContact(testing.allocator, &out, contact);
612 }
613 try out.append(testing.allocator, ']');
614 return out.toOwnedSlice(testing.allocator);
615}
616
617test "a contact prints the keys the shim's JSONObject prints, in that order" {
618 const json = try renderContacts(&.{.{
619 .id = "7",
620 .display_name = "Ada Lovelace",
621 .phone_numbers = &.{ "+441234567890", "555" },
622 .email_addresses = &.{"ada@example.com"},
623 }});
624 defer testing.allocator.free(json);
625
626 // JSONObject is a LinkedHashMap, so this is the order of the shim's
627 // `apply` block. Asserted whole, because the order is what a rewrite loses.
628 try testing.expectEqualStrings(
629 \\[{"id":"7","displayName":"Ada Lovelace","phoneNumbers":["+441234567890","555"],"emailAddresses":["ada@example.com"]}]
630 , json);
631}
632
633test "a contact with no phones or emails still carries both arrays" {
634 // `getContactPhones` always returns a JSONArray, so the keys are present
635 // and empty rather than missing — a page can iterate without checking.
636 const json = try renderContacts(&.{.{
637 .id = "7",
638 .display_name = "Nobody",
639 .phone_numbers = &.{},
640 .email_addresses = &.{},
641 }});
642 defer testing.allocator.free(json);
643
644 try testing.expectEqualStrings(
645 \\[{"id":"7","displayName":"Nobody","phoneNumbers":[],"emailAddresses":[]}]
646 , json);
647}
648
649test "the same null is answered three different ways" {
650 // `put("id", null)` removes the key; `?: ""` makes the name empty; and a
651 // null array element is written as JSON null rather than dropped, so the
652 // array keeps its length.
653 const json = try renderContacts(&.{.{
654 .id = null,
655 .display_name = null,
656 .phone_numbers = &.{ null, "555" },
657 .email_addresses = &.{null},
658 }});
659 defer testing.allocator.free(json);
660
661 try testing.expectEqualStrings(
662 \\[{"displayName":"","phoneNumbers":[null,"555"],"emailAddresses":[null]}]
663 , json);
664
665 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
666 defer parsed.deinit();
667 const first = parsed.value.array.items[0].object;
668 try testing.expect(first.get("id") == null);
669 try testing.expectEqual(@as(usize, 2), first.get("phoneNumbers").?.array.items.len);
670}
671
672test "a name or number carrying a quote survives as JSON" {
673 // Contact names are arbitrary user text and go to the page through the
674 // reply channel — the shape that hung the promise before #154.
675 const json = try renderContacts(&.{.{
676 .id = "it's",
677 .display_name = "O'Brien \"Bob\"",
678 .phone_numbers = &.{"+1 (555) \"x\""},
679 .email_addresses = &.{"a\nb@example.com"},
680 }});
681 defer testing.allocator.free(json);
682
683 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
684 defer parsed.deinit();
685 const first = parsed.value.array.items[0].object;
686 try testing.expectEqualStrings("it's", first.get("id").?.string);
687 try testing.expectEqualStrings("O'Brien \"Bob\"", first.get("displayName").?.string);
688 try testing.expectEqualStrings("+1 (555) \"x\"", first.get("phoneNumbers").?.array.items[0].string);
689 try testing.expectEqualStrings("a\nb@example.com", first.get("emailAddresses").?.array.items[0].string);
690}
691
692test "an empty address book is an empty array" {
693 const json = try renderContacts(&.{});
694 defer testing.allocator.free(json);
695 try testing.expectEqualStrings("[]", json);
696}
697
698test "the query strings are the shim's, character for character" {
699 try testing.expectEqualStrings("display_name ASC", contacts_sort_order);
700 try testing.expectEqualStrings("contact_id = ?", data_selection);
701
702 // Phone.NUMBER and Email.ADDRESS really are the same column: a data row's
703 // meaning comes from its MIMETYPE, and data1 holds whichever value it is.
704 try testing.expectEqualStrings("data1", col_data1);
705 try testing.expectEqualStrings("_id", col_id);
706 try testing.expectEqualStrings("display_name", col_display_name);
707}
708
709test "getContacts names its action and globals as the shim does" {
710 try testing.expectEqualStrings("getContacts", A.get_contacts);
711 try testing.expectEqualStrings("_craftContactsResolve", resolve_global);
712 try testing.expectEqualStrings("_craftContactsReject", reject_global);
713}
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+3-2
Changes to packages/zig/test/android_conformance_test.zig
@@ -50,6 +50,7 @@ const zig_sources = [_][]const u8{
5050 @embedFile("src/bridge_android_calendar.zig"),
5151 @embedFile("src/bridge_android_db.zig"),
5252 @embedFile("src/bridge_android_shareditem.zig"),
53 @embedFile("src/bridge_android_contacts.zig"),
5354};
5455
5556/// How many spec actions Zig does not serve yet.
@@ -69,8 +70,8 @@ const zig_sources = [_][]const u8{
6970/// getCalendarEvents, the first to send the page a payload it built rather
7071/// than a constant; 84 with createCalendarEvent, the first to read one; 82
7172/// with the database pair, the first to borrow a connection the Kotlin owns;
72/// 79 with the shared-item trio.
73const max_not_yet_migrated: usize = 79;
73/// 79 with the shared-item trio; 77 with the contacts pair.
74const max_not_yet_migrated: usize = 77;
7475
7576/// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge.
7677///