Integrating Contacts (ContactsProvider) into Android Applications
Android stores contacts in a three-layer schema: RawContacts (a record from a specific account—Google, Telegram, phone), Contacts (aggregate of multiple RawContacts), and Data (specific fields: number, email, photo). Most developers work only with Contacts and Data tables, ignoring aggregation, and end up with duplicates.
Permissions
READ_CONTACTS and WRITE_CONTACTS are dangerous permissions, requested at runtime. On Android 11+, there's a nuance: if a user declines permission twice, a repeat ActivityCompat.requestPermissions() won't open the dialog—you must navigate the user to app settings via ACTION_APPLICATION_DETAILS_SETTINGS.
Reading Contacts
Read via ContactsContract.CommonDataKinds:
val projection = arrayOf(
ContactsContract.Contacts._ID,
ContactsContract.Contacts.DISPLAY_NAME_PRIMARY,
ContactsContract.Contacts.HAS_PHONE_NUMBER
)
val cursor = context.contentResolver.query(
ContactsContract.Contacts.CONTENT_URI,
projection,
null, null,
"${ContactsContract.Contacts.DISPLAY_NAME_PRIMARY} ASC"
)
cursor?.use {
while (it.moveToNext()) {
val id = it.getLong(it.getColumnIndexOrThrow(ContactsContract.Contacts._ID))
val name = it.getString(it.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY))
val hasPhone = it.getInt(it.getColumnIndexOrThrow(ContactsContract.Contacts.HAS_PHONE_NUMBER)) > 0
if (hasPhone) {
val phoneCursor = context.contentResolver.query(
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER),
"${ContactsContract.CommonDataKinds.Phone.CONTACT_ID} = ?",
arrayOf(id.toString()),
null
)
phoneCursor?.use { pc ->
if (pc.moveToFirst()) {
val number = pc.getString(0)
// save
}
}
}
}
}
Two nested queries per contact is functional but slow. With 500+ contacts, this causes ANR on weak devices when run on the main thread. Solution: CursorLoader or Coroutines + Dispatchers.IO with Flow.
Efficient Approach via Data URI
Instead of dual queries, use one query to ContactsContract.Data.CONTENT_URI filtered by MIMETYPE:
val dataCursor = context.contentResolver.query(
ContactsContract.Data.CONTENT_URI,
arrayOf(
ContactsContract.Data.CONTACT_ID,
ContactsContract.Data.DISPLAY_NAME,
ContactsContract.CommonDataKinds.Phone.NUMBER
),
"${ContactsContract.Data.MIMETYPE} = ?",
arrayOf(ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE),
"${ContactsContract.Data.DISPLAY_NAME} ASC"
)
One query returns all contacts with phones immediately. On a real project with 1200 contacts, this reduced load time from 3.2 seconds to 180 ms.
Creating a Contact
val ops = ArrayList<ContentProviderOperation>()
ops.add(
ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI)
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null)
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null)
.build()
)
ops.add(
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
.withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, "Ivan Petrov")
.build()
)
ops.add(
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
.withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, "+7 999 123 45 67")
.withValue(ContactsContract.CommonDataKinds.Phone.TYPE, ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE)
.build()
)
context.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops)
applyBatch is an atomic operation. If any step fails, the entire transaction rolls back. withValueBackReference(RAW_CONTACT_ID, 0) references the _ID from the first operation's result; without it, the new RawContact and data are unconnected.
Easy-to-Forget Details
On Android 13+ (API 33), READ_CONTACTS has a selective access flag—users can grant access only to some contacts. Your app must work correctly with this limitation and not expect the full list.
Contact photos are a separate query via ContactsContract.Contacts.openContactPhotoInputStream(). Don't load photos in the main query—it heavily impacts memory with large lists.
Timeline: reading contacts takes 1 day; CRUD + account synchronization takes up to 5 days. Cost is calculated individually after requirements analysis.







