Android ContactsContract

Abstract

Programming an Android app with contacts is somewhat complex: You need to understand that one "contact" can be split over several "accounts" (i.e. email, google, outlook, twitter) and bound together by a common "display name". For this reason, contact data is saved in 3 tables, but those are not directly visible and you must use a ContactsContract instead. And no, you do not get a nice contact "object", it is all just atomic data.

1 Contact Tables

Contact data is split into three tables:

Confusingly, the id names below are not actually the ones used, so just roll with it for a moment.

                             Data
+-----------+------------------+-----------------------+-------+
+ DATA_ID 1 | RAW_CONTACT_ID 1 | MimeType Name.First   | Kai   |
+ DATA_ID 2 | RAW_CONTACT_ID 1 | MimeType Phone.Mobile | 05555 | => everything by
+ DATA_ID 3 | RAW_CONTACT_ID 2 | MimeType Name.First   | Kai   |    RAW_CONTACT_ID
+ DATA_ID 4 | RAW_CONTACT_ID 2 | MimeType Note         | Huhu  |
+-----------+------------------+-----------------------+-------+

            RawContacts
+------------------+--------------+
+ RAW_CONTACT_ID 1 | CONTACT_ID 1 |
+ RAW_CONTACT_ID 2 | CONTACT_ID 1 | => aggregating by CONTACT_ID
+------------------+--------------+

    Contacts
+---------------+--------------+
|  CONTACT_ID 1 | CreationDate | => nothing useful, as mapping is in RawContacts
+---------------+--------------+

The primary key in each table is BaseColumns._ID ="_id" (but using different constants for some reason), and the foreign key differs.

Or graphically:

Now we will go for CRUD: Create, read, update, delete.

2 Create a Contact

First you need an account (or two) on your emulator or device; you can use throw-away emails to create them (Settings->Accounts). Creating a contact has two steps:

The preferred method in the year 2020 is to assemble operations and execute them in batch.

ArrayList<ContentProviderOperation> ops = new ArrayList<>()
ContentProviderOperation.Builder op = ...;
ops.add(op.build());
contentResolver.applyBatch(ContactsContract.AUTHORITY, ops);

You see that right, applyBatch specifically requests an ArrayList, not a List. So now on to creating a 2-raw, 1-uni contact:

2.1 Create a 1-raw, 1-uni Contact

We start by adding an empty raw contact. To make this easier to read, ContactsContract.* is imported.

op = ContentProviderOperation.newInsert(
  RawContacts.CONTENT_URI)
  .withValue(RawContacts.ACCOUNT_TYPE, "com.android.email")
  .withValue(RawContacts.ACCOUNT_NAME, "kai@example.com");

We specify the URI of the raw table, account name and type, and the raw contact is created. Android automatically maps the newly created raw id to a uni id, which is taken from a different global counter, and may or may not be the same as the raw id.

Now we add data, more specifically a name. First and last name combined must be at least 3 characters.

op = ContentProviderOperation.newInsert(
  Data.CONTENT_URI)
  .withValueBackReference(Data.RAW_CONTACT_ID, /*previousResult*/ 0)
  .withValue(Data.MIMETYPE, CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
  .withValue(CommonDataKinds.StructuredName.GIVEN_NAME, "Kai");

Now this is interesting: We never get to see the raw id, but just say "use the one of the previous operation". The StructuredName is important later on, because Android generates the DISPLAY_NAME from it (and no, there is no way to call this by yourself), which is used to unify raw contacts. And also, if the display name is shorter than 3 chars, an exception will be thrown when executing the batch.

For the data itself, we specify a MIME type and the value (apparently, a default type mapping was too hard for Google). Now we have a name, but we also want a "payload":

op = ContentProviderOperation.newInsert(
  Data.CONTENT_URI)
  .withValueBackReference(Data.RAW_CONTACT_ID, 0)
  .withValue(Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
  .withValue(CommonDataKinds.Phone.NUMBER, "05555")
  .withValue(CommonDataKinds.Phone.TYPE, CommonDataKinds.Phone.TYPE_MOBILE);

Again, we specify MIME type and data content, and use the previous raw id.

2.2. Create a 2-raw, 1-uni Contact

To create a second raw contact, repeat the first two code blocks of the previous subsection, but with a different email account, so we have another (relatively empty) raw contact with the name "Kai", which leads to the same display name, which leads to contact unification. At least on the emulator, I noticed that the new uni id is incremented for both raw contacts (it does not stay the same for the first raw contact!).

Now with some payload for the second raw contact:

op = ContentProviderOperation.newInsert(
  Data.CONTENT_URI)
  .withValueBackReference(Data.RAW_CONTACT_ID, 0)
  .withValue(Data.MIMETYPE, CommonDataKinds.Note.CONTENT_ITEM_TYPE)
  .withValue(CommonDataKinds.Note.NOTE, "Huhu");

And with that, we should have created our tables from the top of the page: Contact unification happened in the background, without us doing anything.

3 Read a Contact

For reading, we will not use the tables directly, instead going for so-called "projections" (basically an SQL join). The funny thing is we always use the Data table (sorry, "projection") which automatically includes all relevant ids.

String[] READ_CONTACTS_PROJECTION = {
  Data._ID,                                   // "_id"
  Data.RAW_CONTACT_ID,                        // "raw_contact_id"
  Data.CONTACT_ID,                            // "contact_id"
  Data.DISPLAY_NAME,                          // "display_name"
  Data.MIMETYPE,                              // "mimetype"
  CommonDataKinds.StructuredName.GIVEN_NAME,  // "data2"
  CommonDataKinds.Phone.NUMBER,               // "data1"
  CommonDataKinds.Phone.TYPE,                 // "data2"
  CommonDataKinds.Phone.LABEL,                // "data2"
  CommonDataKinds.Note.NOTE,                  // "data1"
};

The first thing we notice is that data, raw and uni id are in the same projection. The second thing is that data1..n repeats awfully often; that is why the mimetype is important so we can sort them out later. We get a cursor on the Data table and load:

Cursor cursor = contentResolver.query(
  Data.CONTENT_URI,
  READ_CONTACTS_PROJECTION,
  /*selectionVars*/ null, /*selectionArgs*/ null,
  /*sortOrder*/ null);

This is where you can put in a selection (e.g. "display_name=?") and args (e.g. {"Kai"}), as well as sort criteria. But more relevant is that the cursor now has one data entry per row, and we use the mime type to find out what it is.

while (cursor != null && cursor.moveToNext()) {
  // Wrangle IDs.
  String uniId = cursor.getString(cursor.getColumnIndex(Data.CONTACT_ID));
  String rawId = cursor.getString(cursor.getColumnIndex(Data.RAW_CONTACT_ID));
  if (someMap.contains (uniId)) { ... } // this belongs to the same uni contact

  // Wrangle data.
  String mimeType = cursor.getString(cursor.getColumnIndex(Data.MIMETYPE));
  if (mimeType.equals(CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)) {
    String givenName = cursor.getString(cursor.getColumnIndex(CommonDataKinds.StructuredName.GIVEN_NAME));
  }
  else if (mimeType.equals(CommonDataKinds.Note.CONTENT_ITEM_TYPE)) {
    String note = cursor.getString(cursor.getColumnIndex(CommonDataKinds.Note.NOTE));
  }
  else if (mimeType.equals(CommonDataKinds.Phone.CONTENT_ITEM_TYPE)) {
    String phoneNumber = cursor.getString(cursor.getColumnIndex(CommonDataKinds.Phone.NUMBER));
  }
}

The ids are the same for each row. But each row serves only one mime type, and now you can puzzle your contact together from this.

4 Update a Contact

Updating a data row is similar to creation, only that this time we need to make a selection on uni id and mime type, instead of supplying them as values.

String   selectionText = Data.CONTACT_ID + "=? AND " + Data.MIMETYPE + "=?";
String[] selectionArgs = new String[]{uniId, CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE};
op = ContentProviderOperation.newUpdate(
  Data.CONTENT_URI)
  .withSelection(selectionText, selectionArgs)
  .withValue(CommonDataKinds.StructuredName.GIVEN_NAME, givenName);

Note that this should change the name in both raw contacts, while a change in phone number should only affect the raw contact that actually contains the phone number.

A special case is the displayName, partly because any update in a structured name will implicitly update the display name. Since the raw and uni levels can identify by display name, we need to change both data and raw tables (in this order) with CommonDataKinds.StructuredName.DISPLAY_NAME and RawContacts.DISPLAY_NAME_PRIMARY, respectively.

5 Delete a Contact

Depending on how badly you messed around (e.g. doing contacts with null account), scrubbing a contact has to happen on uni, raw and data level. We start with the data level.

String   selectionData = Data.CONTACT_ID + "=?";
String[] selectionArgs = new String[]{uniId};
op = ContentProviderOperation.newDelete(
  Data.CONTENT_URI)
  .withSelection(selectionData, selectionArgs);

And continue on raw level. Strictly speaking, only raw level is required because it will implicitly delete all associated data.

String   selectionRaw  = RawContacts._ID + "=?";
String[] selectionArgs = new String[]{rawId};
op = ContentProviderOperation.newDelete(
  RawContacts.CONTENT_URI)
  .withSelection(selectionRaw, selectionArgs);

And finally the uni level. This should already have been done implicitly by the raw level.

String   selectionUni  = Contacts._ID + "=?";
String[] selectionArgs = new String[]{uniId};
op = ContentProviderOperation.newDelete(
  Contacts.CONTENT_URI)
  .withSelection(selectionUni, selectionArgs);

Alternatively, the selection can be on DISPLAY_NAME because it exists in all three tables (as DISPLAY_NAME_PRIMARY in RawContacts).

Another thing to note is that the contact is not really deleted -- the sync adapter of the account still has the chance to tell the remote end that the contact is gone. To ignore this, we adjust the URI for the delete operation:

RawContacts.CONTENT_URI.buildUpon().appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build();

We pretend to be a sync adapter to fully purge the contact.

Conclusion

And so we have the CRUD cycle of Android contacts. Hope this article could help you, and have a nice day!

EOF (Aug:2020)