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/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}