| @@ -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 | +} |