⋯ 40 lines Show the 40 lines above this hunk @@ -41,10 +41,13 @@ const jobject = jni.jobject; 41 41 42 42 pub const A = struct { 43 43 pub const get_contacts = "getContacts"; 44 + pub const add_contact = "addContact"; 44 45 }; 45 46 46 47 pub const resolve_global = "_craftContactsResolve"; 47 48 pub const reject_global = "_craftContactsReject"; 49 + pub const add_resolve_global = "_craftAddContactResolve"; 50 + pub const add_reject_global = "_craftAddContactReject"; 48 51 49 52 const col_id = "_id"; 50 53 const col_display_name = "display_name"; ⋯ 269 lines Show the 269 lines above this hunk @@ -320,6 +323,279 @@ fn javaString(j: Jni, allocator: std.mem.Allocator, text: []const u8) !jni.jstri 320 323 return j.newStringUtf(terminated.ptr); 321 324 } 322 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 + 323 599 // ============================================================================= 324 600 // Tests 325 601 // ============================================================================= ⋯ 109 lines Show the 109 lines above this hunk @@ -435,3 +711,107 @@ test "getContacts names its action and globals as the shim does" { 435 711 try testing.expectEqualStrings("_craftContactsResolve", resolve_global); 436 712 try testing.expectEqualStrings("_craftContactsReject", reject_global); 437 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 + }