The Contacts Provider automatically aggregates similar RawContacts into a single Contact when it determines that they reference the same person.
However, the Contacts Provider's aggregation algorithms are only as accurate as the Data belonging to these RawContacts. Sometimes, they are not enough to determine if they indeed are the same person. With this in mind, the Contacts Provider allows us to explicitly and forcefully specify whether two or more RawContacts reference the same person (Contact) or not.
⚠️ The APIs for this have changed significantly since version 0.3.0. For documentation for version 0.2.4 and below, visit this page (click me).
There are two ways to link Contacts.
To link three Contacts and all of their constituent RawContacts into a single Contact using
extensions from contacts.core.util.ContactLinks.kt
,
val linkResult = contact1.linkDirect(contactsApi, contact2, contact3)
ℹ️ Prior to version 0.3.0, this function was named
link
.
To link three Contacts and all of their constituent RawContacts into a single Contact using the
ContactLink
API,
val linkResult = contactsApi
.aggregationExceptions()
.link()
.contacts(contact1, contact2, contact3)
.commit()
ℹ️ The
ContactLink
API was not available prior to version 0.3.0.
The above examples links (keep together) all RawContacts belonging to contact1
, contact2
, and
contact3
into a single Contact.
Aggregation is done by the Contacts Provider. For example,
- Contact (id: 1, display name: A)
- RawContact A
- Contact (id: 2, display name: B)
- RawContact B
- RawContact C
Linking Contact 1 with Contact 2 results in;
- Contact (id: 1, display name: A)
- RawContact A
- RawContact B
- RawContact C
Contact 2 no longer exists and all of the Data belonging to RawContact B and C are now associated with Contact 1.
If instead Contact 2 is linked with Contact 1;
- Contact (id: 1, display name: B)
- RawContact A
- RawContact B
- RawContact C
The same thing occurs except the display name has been set to the display name of RawContact B.
This function only instructs the Contacts Provider which RawContacts should be aggregated to a single Contact. Details on how RawContacts are aggregated into a single Contact are left to the Contacts Provider.
ℹ️ Profile Contact/RawContacts are not supported! This operation will fail if given any profile Contact/RawContacts .
To check if the link succeeded,
val linkSuccessful = linkResult.isSuccessful
To get the ID of the parent Contact of all linked RawContacts,
val contactId: Long? = linkResult.contactId
ℹ️ The
contactId
will belong to one of the linked Contacts.
Once you have the Contact ID, you can retrieve the Contact via the Query
API,
val contact = contactsApi
.query()
.where { Contact.Id equalTo contactId }
.find()
ℹ️ For more info, read Query contacts (advanced).
Alternatively, you may use the extensions provided in contact.core.util.ContactLinkResult.kt
.
To get the parent Contact of all linked RawContacts,
val contact = linkResult.contact(contactsApi)
There are two ways to unlink a Contact.
To unlink a Contacts with more than one RawContact into a separate Contacts using extensions from
contacts.core.util.ContactLinks.kt
,
val unlinkResult = contact.unlinkDirect(contactsApi)
ℹ️ Prior to version 0.3.0, this function was named
unlink
.
To unlink a Contacts with more than one RawContact into a separate Contacts using the
ContactUnlink
API,
val unlinkResult = contactsApi
.aggregationExceptions()
.unlink()
.contact(contact)
.commit()
ℹ️ The
ContactUnlink
API was not available prior to version 0.3.0.
The above unlinks (keep separate) all RawContacts belonging to the contact
into separate
Contacts.
The above does nothing and will fail if the Contact only has one constituent RawContact.
ℹ️ Profile Contact/RawContacts are not supported! This operation will fail if given any profile Contact/RawContacts .
To check if the unlink succeeded,
val unlinkSuccessful = unlinkResult.isSuccessful
To get the IDs of the constituent RawContact of of the Contact that has been unlinked,
val rawContactIds = unlinkResult.rawContactIds
Once you have the RawContact IDs, you can retrieve the corresponding Contacts via the Query
API,
val contacts = contactsApi
.query()
.where { RawContact.Id `in` rawContactIds }
.find()
ℹ️ For more info, read Query contacts (advanced).
Alternatively, you may use the extensions provided in contact.core.util.ContactUnlinkResult.kt
.
To get the Contacts of all unlinked RawContacts,
val contacts = unlinkResult.contacts(contactsApi)
Linking or unlinking contacts is done in the same thread as the call-site. This may result in a choppy UI.
To perform the work in a different thread, use the Kotlin coroutine extensions provided in
the async
module. For more info,
read Execute work outside of the UI thread using coroutines.
You may, of course, use other multi-threading libraries or just do it yourself =)
ℹ️ Extensions for Kotlin Flow and RxJava are also in the project roadmap.
Linking/unlinking requires the android.permission.WRITE_CONTACTS
permission. If not granted,
linking/unlinking data will fail.
To perform the link/unlink with permission, use the extensions provided in the permissions
module.
For more info, read Permissions handling using coroutines.
You may, of course, use other permission handling libraries or just do it yourself =)
You may link Contacts with RawContacts that belong to different Accounts. Any RawContact Data modifications are synced per Account sync settings.
ℹ️ For more info, read Sync contact data across devices.
RawContacts that are not associated with an Account are local to the device and therefore will not be synced even if it is linked to a Contact with a RawContact that is associated with an Account.
ℹ️ For more info, read about Local (device-only) contacts.
The AOSP Contacts app terminology has changed over time;
- API 22 and below; join / separate
- API 23; merge / unmerge
- API 24 and above; link / unlink
However, the internals have not changed; KEEP_TOGETHER
/ KEEP_SEPARATE
. These operations are
supported by the ContactsContract.AggregationExceptions
.
For example, given the following tables,
### Contacts table
Contact id: 32, displayName: X, starred: 0, timesContacted: 1, lastTimeContacted: 1573071785456, customRingtone: content://media/internal/audio/media/109, sendToVoicemail: 0
Contact id: 33, displayName: Y, starred: 1, timesContacted: 2, lastTimeContacted: 1573071750624, customRingtone: content://media/internal/audio/media/115, sendToVoicemail: 1
### RawContacts table
RawContact id: 30, contactId: 32, displayName: X, accountName: [email protected], accountType: com.google, starred: 0, timesContacted: 1, lastTimeContacted: 1573071785456, customRingtone: content://media/internal/audio/media/109, sendToVoicemail: 0
RawContact id: 31, contactId: 33, displayName: Y, accountName: [email protected], accountType: com.google, starred: 1, timesContacted: 2, lastTimeContacted: 1573071750624, customRingtone: content://media/internal/audio/media/115, sendToVoicemail: 1
### Data table
Data id: 57, rawContactId: 30, contactId: 32, mimeType: vnd.android.cursor.item/group_membership, data1: 18
Data id: 58, rawContactId: 30, contactId: 32, mimeType: vnd.android.cursor.item/name, data1: X, isPrimary: 1, isSuperPrimary: 1
Data id: 59, rawContactId: 30, contactId: 32, mimeType: vnd.android.cursor.item/email_v2, data1: [email protected]
Data id: 60, rawContactId: 30, contactId: 32, mimeType: vnd.android.cursor.item/email_v2, data1: [email protected], isPrimary: 1, isSuperPrimary: 1
Data id: 63, rawContactId: 31, contactId: 33, mimeType: vnd.android.cursor.item/group_membership, data1: 6
Data id: 64, rawContactId: 31, contactId: 33, mimeType: vnd.android.cursor.item/name, data1: Y, isPrimary: 1, isSuperPrimary: 1
Data id: 65, rawContactId: 31, contactId: 33, mimeType: vnd.android.cursor.item/email_v2, data1: [email protected]
Data id: 66, rawContactId: 31, contactId: 33, mimeType: vnd.android.cursor.item/email_v2, data1: [email protected], isPrimary: 1, isSuperPrimary: 1
When Contact X links/merges/joins Contact Y, the tables becomes;
### Contacts table
Contact id: 32, displayName: X, starred: 1, timesContacted: 2, lastTimeContacted: 1573071785456, customRingtone: content://media/internal/audio/media/109, sendToVoicemail: 0
### RawContacts table
RawContact id: 30, contactId: 32, displayName: X, accountName: [email protected], accountType: com.google, starred: 0, timesContacted: 1, lastTimeContacted: 1573071785456, customRingtone: content://media/internal/audio/media/109, sendToVoicemail: 0
RawContact id: 31, contactId: 32, displayName: Y, accountName: [email protected], accountType: com.google, starred: 1, timesContacted: 2, lastTimeContacted: 1573071750624, customRingtone: content://media/internal/audio/media/115, sendToVoicemail: 1
### Data table
Data id: 57, rawContactId: 30, contactId: 32, mimeType: vnd.android.cursor.item/group_membership, data1: 18
Data id: 58, rawContactId: 30, contactId: 32, mimeType: vnd.android.cursor.item/name, data1: X, isPrimary: 1, isSuperPrimary: 1
Data id: 59, rawContactId: 30, contactId: 32, mimeType: vnd.android.cursor.item/email_v2, data1: [email protected]
Data id: 60, rawContactId: 30, contactId: 32, mimeType: vnd.android.cursor.item/email_v2, data1: [email protected], isPrimary: 1, isSuperPrimary: 0
Data id: 63, rawContactId: 31, contactId: 32, mimeType: vnd.android.cursor.item/group_membership, data1: 6
Data id: 64, rawContactId: 31, contactId: 32, mimeType: vnd.android.cursor.item/name, data1: Y, isPrimary: 1, isSuperPrimary: 0
Data id: 65, rawContactId: 31, contactId: 32, mimeType: vnd.android.cursor.item/email_v2, data1: [email protected]
Data id: 66, rawContactId: 31, contactId: 33, mimeType: vnd.android.cursor.item/email_v2, data1: [email protected], isPrimary: 1, isSuperPrimary: 0
What changed?
Contact Y's row has been deleted and its column values have been merged into Contact X row. If the reverse occurred (Contact Y merged with Contact X), Contact Y's row would still be deleted. The difference is that Contact X's display name will be set to Contact Y's display name, which is done by the AOSP Contacts app manually by setting Contact Y's Data name row to be the "default" (isPrimary and isSuperPrimary both set to 1).
ℹ️ The AggregationExceptions table records the linked RawContacts IDs in ascending order regardless of the order used in RAW_CONTACT_ID1 and RAW_CONTACT_ID2 at the time of merging.
The RawContacts and Data table remains the same except the joined contactId column values have now been changed to the id of Contact X. All Data rows' isSuperPrimary value has been set to 0 though the isPrimary columns remain the same. In other words, this clears any "default" set before the link. These are done automatically by the Contacts Provider during the link operation.
What is not done automatically by the Contacts Provider is that the name row of former Contact X is set as the default. The AOSP Contacts app does this manually. The Contacts Providers automatically sets the Contact display name to whatever the default name row is for the Contact, if available. For more info on Contact display name resolution, read the Contact Display Name and Default Name Rows section.
ℹ️ Display name resolution is different for APIs below 21 (pre-lollipop).
The display name of the RawContacts remain the same.
The Groups table remains unmodified.
Options updates
Changes to the options (starred, timesContacted, lastTimeContacted, customRingtone, and sendToVoicemail) of a RawContact may affect the options of the parent Contact. On the other hand, changes to the options of the parent Contact will be propagated to all child RawContact options.
Photo updates
A RawContact may have a full-sized photo saved as a file and a thumbnail version of that saved in the Data table in a photo mimetype row. A Contact's full-sized photo and thumbnail are simply references to the "chosen" RawContact's full-sized photo and thumbnail (though the URIs may differ).
ℹ️ When removing the photo in the AOSP contacts app, the photo data row is not immediately deleted, though the
PHOTO_FILE_ID
is immediately set to null. This may result in thePHOTO_URI
andPHOTO_THUMBNAIL_URI
to still have a valid image uri even though the photo has been "removed". This library immediately deletes the photo data row, which seems to work perfectly.
Data inserts
In the AOSP Contacts app, Data inserted in combined (raw) contacts mode will be associated to the first RawContact in the list sorted by the RawContact ID.
ℹ️ This may not be the same as the RawContact referenced by
ContactsColumns.NAME_RAW_CONTACT_ID
.
UI changes?
The AOSP Contacts App does not display the groups field when displaying / editing Contacts that have multiple RawContacts (linked/merged/joined) in combined mode. However, it does allow editing individual RawContact Data rows in which case the groups field is displayed and editable.
In the AOSP Contacts app, the name attribute used comes from the name row with IS_SUPER_PRIMARY set to true. This and all other "unique" mimetypes (organization) and non-unique mimetypes (email) per RawContact are shown only if they are not blank.
Showing multiple RawContact's data in the same edit screen (combined mode)
In older version of the AOSP, Android Open Source Project (AOSP) Contacts app, data from multiple RawContacts was being shown in the same edit screen. This caused a lot of confusion about which data belonged to which RawContact. Newer versions of AOSP Contacts only allow editing one RawContact at a time to avoid confusion. Though, several RawContacts' data are still shown (not-editable) in the same screen.
Given the following Contacts and their RawContacts;
- Contact A
- RawContact 1
- Contact B
- RawContact 2
- Contact C
- RawContact 3
- Contact D
- RawContact 4
Linking one by one in this order;
- Contact B link Contact A
- Contact C link Contact D
- Contact C link Contact B
Results in the following AggregationExceptions rows respectively;
Aggregation exception id: 430, type: 1, rawContactId1: 1, rawContactId2: 2
Aggregation exception id: 430, type: 1, rawContactId1: 1, rawContactId2: 2
Aggregation exception id: 432, type: 1, rawContactId1: 3, rawContactId2: 4
Aggregation exception id: 436, type: 1, rawContactId1: 1, rawContactId2: 2
Aggregation exception id: 439, type: 1, rawContactId1: 1, rawContactId2: 3
Aggregation exception id: 442, type: 1, rawContactId1: 1, rawContactId2: 4
Aggregation exception id: 440, type: 1, rawContactId1: 2, rawContactId2: 3
Aggregation exception id: 443, type: 1, rawContactId1: 2, rawContactId2: 4
Aggregation exception id: 444, type: 1, rawContactId1: 3, rawContactId2: 4
There is a pattern here. RawContact ids are sorted in ascending order and linked from least to greatest exhaustively but no double links (1-2 is the same as 2-1).
- RawContact 1 has a row with RawContact 2, 3, and 4.
- RawContact 2 has a row with RawContact 3 and 4.
- RawContact 3 has a row with RawContact 4.
Linking all in one go;
- Contact C link Contact A, B, D
Results in the same AggregationExceptions rows.
Unlinking results in the same AggregationExceptions rows except the type is 2
(TYPE_KEEP_SEPARATE
).
If available, the "default" (isPrimary and isSuperPrimary set to 1) name row for a Contact is automatically set as the Contact display name by the Contacts Provider. Otherwise, the Contacts Provider chooses from any of the other suitable data from the aggregate Contact.
ℹ️ The
ContactsColumns.NAME_RAW_CONTACT_ID
is automatically updated by the Contacts Provider along with the display name.
The default status of other sources (e.g. email) does not affect the Contact display name.
The AOSP Contacts app also sets the most recently updated name as the default at every update. This results in the Contact display name changing to the most recently updated name from one of the associated RawContacts. The "most recently updated name" is the name field that was last updated by the user when editing in the Contacts app, which is irrelevant to its value. It does not matter if the user deleted the last character of the name, added the same character back, and then saved. It still counts as the most recently updated.
All of the above only applies to API 21 and above.
Display name resolution is different for APIs below 21 (pre-Lollipop)!
The ContactsColumns.NAME_RAW_CONTACT_ID
was added in API 21. It changed the way display names are
resolved for Contacts with more than one constituent RawContacts, which is what has been described
so far.
Before this change (APIs 20 and below), the AOSP Contacts app is still able to set the Contact
display name somehow. I'm not sure how. If someone figures it out, please let me know. I tried
updating the Contact DISPLAY_NAME
directly but it does not work. Setting a name row as default
also does not affect the Contact DISPLAY_NAME
.
When two or more Contacts (along with their constituent RawContacts) are linked into a single Contact those Contacts will be merged into one of the existing Contact row. The Contacts that have been merged into the single Contact will have their entries/rows in the Contacts table deleted.
Unlinking will result in the original Contacts prior to linking to have new rows in the Contacts table with different IDs because the previously deleted row IDs cannot be reused.
Getting Contacts that have been linked into a single Contact or Contacts whose row IDs have change after unlinking is still possible using the Contact lookup key.
For more info, read about Contact lookup key vs ID.