also looking at this
feat(android): the contacts pair in Zig — ratchet 79 → 77
#166
7 files
+974
-3
| @@ -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 | ||
| 35 | const std = ; | |
| 36 | const jni = ; | |
| 37 | const bridge_error = ; | |
| 38 | ||
| 39 | const Jni = jni.Jni; | |
| 40 | const jobject = jni.jobject; | |
| 41 | ||
| 42 | pub const A = struct { | |
| 43 | pub const get_contacts = "getContacts"; | |
| 44 | pub const add_contact = "addContact"; | |
| 45 | }; | |
| 46 | ||
| 47 | pub const resolve_global = "_craftContactsResolve"; | |
| 48 | pub const reject_global = "_craftContactsReject"; | |
| 49 | pub const add_resolve_global = "_craftAddContactResolve"; | |
| 50 | pub const add_reject_global = "_craftAddContactReject"; | |
| 51 | ||
| 52 | const col_id = "_id"; | |
| 53 | const col_display_name = "display_name"; | |
| 54 | ||
| 55 | /// `Phone.CONTACT_ID` and `Email.CONTACT_ID`, the join back to the contact. | |
| 56 | const col_contact_id = "contact_id"; | |
| 57 | ||
| 58 | /// `Phone.NUMBER` and `Email.ADDRESS`, both of which are `DATA1`. | |
| 59 | const col_data1 = "data1"; | |
| 60 | ||
| 61 | const contacts_sort_order = col_display_name ++ " ASC"; | |
| 62 | const data_selection = col_contact_id ++ " = ?"; | |
| 63 | ||
| 64 | /// One contact, as the two cursors give it. | |
| 65 | pub 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. | |
| 73 | pub 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. | |
| 103 | fn 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 | ||
| 122 | fn 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. | |
| 136 | pub 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`. | |
| 202 | fn 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 | ||
| 242 | fn 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. | |
| 258 | fn 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 | ||
| 296 | fn 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 | ||
| 307 | fn 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 | ||
| 319 | fn 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 | ||
| 352 | const col_account_type = "account_type"; | |
| 353 | const col_account_name = "account_name"; | |
| 354 | const col_raw_contact_id = "raw_contact_id"; | |
| 355 | const col_mimetype = "mimetype"; | |
| 356 | ||
| 357 | /// `Phone.TYPE` and `Email.TYPE`, both of which are `DATA2`. | |
| 358 | const col_data2 = "data2"; | |
| 359 | ||
| 360 | /// `Phone.TYPE_MOBILE` and `Email.TYPE_HOME`. | |
| 361 | const phone_type_mobile: i32 = 2; | |
| 362 | const email_type_home: i32 = 1; | |
| 363 | ||
| 364 | /// `ContactsContract.AUTHORITY`. | |
| 365 | const contacts_authority = "com.android.contacts"; | |
| 366 | ||
| 367 | /// The three optional fields the shim reads out of the payload. | |
| 368 | pub 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. | |
| 382 | pub 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 | ||
| 395 | fn 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. | |
| 409 | pub 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 | ||
| 500 | const 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 | ||
| 507 | fn 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 | ||
| 557 | fn 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 | ||
| 570 | fn 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 | ||
| 588 | fn 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 | ||
| 603 | const testing = std.testing; | |
| 604 | ||
| 605 | fn 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 | ||
| 617 | test "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 | ||
| 633 | test "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 | ||
| 649 | test "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 | ||
| 672 | test "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 | ||
| 692 | test "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 | ||
| 698 | test "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 | ||
| 709 | test "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 | ||
| 717 | fn 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 | ||
| 732 | fn 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 | ||
| 738 | test "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 | ||
| 749 | test "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 | ||
| 761 | test "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 | ||
| 775 | test "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 | ||
| 796 | test "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 | ||
| 813 | test "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 | } | |