Your app needs contacts. Maybe it's a messaging feature, a quick-dial screen, or just showing the user which of their friends are already on the platform. Whatever the reason, reading phone contacts in Android is one of those tasks that's easy to get working but surprisingly easy to get wrong — duplicate entries, missing names, ANRs on large contact lists.
This guide covers the complete contact syncing flow with Kotlin — the right permission approach, querying with ContentResolver, deduplicating linked accounts, fetching phone numbers and emails, and making sure the whole thing runs off the main thread so your UI never freezes.
Step 1 — Declare Permission in AndroidManifest.xml
<!-- AndroidManifest.xml --> <uses-permission android:name="android.permission.READ_CONTACTS"/>
Step 2 — Request Permission at Runtime
Use the modern ActivityResultContracts approach — onRequestPermissionsResult is deprecated since API 30:
import androidx.activity.result.contract.ActivityResultContracts
class ContactsActivity : AppCompatActivity() {
private val requestContactsPermission = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
loadContacts()
} else {
if (!shouldShowRequestPermissionRationale(Manifest.permission.READ_CONTACTS)) {
// Permanently denied — open settings
showSettingsDialog()
} else {
Toast.makeText(this, "Contacts permission is required", Toast.LENGTH_SHORT).show()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_contacts)
checkAndRequestPermission()
}
private fun checkAndRequestPermission() {
when {
ContextCompat.checkSelfPermission(
this, Manifest.permission.READ_CONTACTS
) == PackageManager.PERMISSION_GRANTED -> {
loadContacts()
}
shouldShowRequestPermissionRationale(Manifest.permission.READ_CONTACTS) -> {
showRationaleDialog()
}
else -> {
requestContactsPermission.launch(Manifest.permission.READ_CONTACTS)
}
}
}
private fun showRationaleDialog() {
AlertDialog.Builder(this)
.setTitle("Contacts Access Needed")
.setMessage("This app needs access to your contacts to show your friends list.")
.setPositiveButton("Allow") { _, _ ->
requestContactsPermission.launch(Manifest.permission.READ_CONTACTS)
}
.setNegativeButton("Cancel", null)
.show()
}
private fun showSettingsDialog() {
AlertDialog.Builder(this)
.setTitle("Permission Required")
.setMessage("Please enable contacts permission in app Settings.")
.setPositiveButton("Open Settings") { _, _ ->
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", packageName, null)
}
startActivity(intent)
}
.setNegativeButton("Cancel", null)
.show()
}
}
Step 3 — Define the Contact Data Model
data class ContactDTO(
var contactId: Long = 0L,
var firstName: String? = null,
var middleName: String? = null,
var lastName: String? = null,
var mobileNumberList: MutableList<String> = mutableListOf(),
var emailList: MutableList<String> = mutableListOf(),
var photoUri: String? = null
) {
// Full name helper
val displayName: String
get() = listOfNotNull(firstName, middleName, lastName)
.joinToString(" ")
.ifBlank { "Unknown" }
}
Step 4 — Basic Contact Sync (Name + Phone Number)
Always run contact queries off the main thread — a user with 2,000 contacts will cause an ANR if you query on the UI thread. Use viewModelScope with Dispatchers.IO:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
// In your ViewModel
class ContactsViewModel(application: Application) : AndroidViewModel(application) {
private val _contacts = MutableStateFlow<List<ContactDTO>>(emptyList())
val contacts: StateFlow<List<ContactDTO>> = _contacts
fun loadContacts() {
viewModelScope.launch {
_contacts.value = fetchContacts()
}
}
private suspend fun fetchContacts(): List<ContactDTO> =
withContext(Dispatchers.IO) {
val contactMap = mutableMapOf<Long, ContactDTO>()
val projection = arrayOf(
ContactsContract.CommonDataKinds.Phone.CONTACT_ID,
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
ContactsContract.CommonDataKinds.Phone.NUMBER
)
val cursor = getApplication<Application>().contentResolver.query(
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
projection,
null,
null,
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME + " ASC"
)
cursor?.use {
val idIndex = it.getColumnIndexOrThrow(
ContactsContract.CommonDataKinds.Phone.CONTACT_ID)
val nameIndex = it.getColumnIndexOrThrow(
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)
val numberIndex = it.getColumnIndexOrThrow(
ContactsContract.CommonDataKinds.Phone.NUMBER)
while (it.moveToNext()) {
val contactId = it.getLong(idIndex)
val name = it.getString(nameIndex) ?: continue
val number = it.getString(numberIndex) ?: continue
// Deduplicate by contactId — prevents duplicates from
// linked accounts (WhatsApp, Google etc.)
val contact = contactMap.getOrPut(contactId) {
ContactDTO(contactId = contactId, firstName = name)
}
// A contact can have multiple numbers — add all
if (number.isNotBlank() && !contact.mobileNumberList.contains(number)) {
contact.mobileNumberList.add(number)
}
}
}
contactMap.values.toList()
}
}
getColumnIndex() returns -1 silently if the column doesn't exist — your app then crashes with an IllegalArgumentException on the next line with a confusing error message. getColumnIndexOrThrow() throws immediately with a clear message pointing to the missing column. Always use getColumnIndexOrThrow().
Step 5 — Advanced Sync (Name + Phone + Email + Photo)
For full contact details, query the ContactsContract.Data table and switch on MIME type to extract each data type:
private suspend fun fetchDetailedContacts(): List<ContactDTO> =
withContext(Dispatchers.IO) {
val contactMap = mutableMapOf<Long, ContactDTO>()
val projection = arrayOf(
ContactsContract.Data.CONTACT_ID,
ContactsContract.Data.MIMETYPE,
ContactsContract.Data.DATA1,
ContactsContract.Data.DATA2,
ContactsContract.Data.DATA3,
ContactsContract.Data.DATA5
)
val cursor = getApplication<Application>().contentResolver.query(
ContactsContract.Data.CONTENT_URI,
projection,
null,
null,
null
)
cursor?.use {
val idIndex = it.getColumnIndexOrThrow(ContactsContract.Data.CONTACT_ID)
val mimeIndex = it.getColumnIndexOrThrow(ContactsContract.Data.MIMETYPE)
while (it.moveToNext()) {
val contactId = it.getLong(idIndex)
val mimeType = it.getString(mimeIndex) ?: continue
val contact = contactMap.getOrPut(contactId) {
ContactDTO(contactId = contactId)
}
when (mimeType) {
// Phone number
ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE -> {
val number = it.getString(
it.getColumnIndexOrThrow(ContactsContract.Data.DATA1)
)
if (!number.isNullOrBlank() &&
!contact.mobileNumberList.contains(number)) {
contact.mobileNumberList.add(number)
}
}
// Structured name
ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE -> {
contact.firstName = it.getString(
it.getColumnIndexOrThrow(ContactsContract.Data.DATA2))
contact.middleName = it.getString(
it.getColumnIndexOrThrow(ContactsContract.Data.DATA5))
contact.lastName = it.getString(
it.getColumnIndexOrThrow(ContactsContract.Data.DATA3))
}
// Email address
ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE -> {
val email = it.getString(
it.getColumnIndexOrThrow(ContactsContract.Data.DATA1))
if (!email.isNullOrBlank() &&
!contact.emailList.contains(email)) {
contact.emailList.add(email)
}
}
// Contact photo URI
ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE -> {
val photoUri = ContentUris.withAppendedId(
ContactsContract.Contacts.CONTENT_URI, contactId
).toString()
contact.photoUri = photoUri
}
}
}
}
// Filter out contacts with no name and no number
contactMap.values
.filter { it.displayName != "Unknown" || it.mobileNumberList.isNotEmpty() }
.sortedBy { it.displayName }
}
Step 6 — Display in Fragment
class ContactsFragment : Fragment() {
private val viewModel: ContactsViewModel by activityViewModels()
private lateinit var adapter: ContactsAdapter
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
adapter = ContactsAdapter()
binding.recyclerView.adapter = adapter
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.contacts.collect { contacts ->
binding.progressBar.isVisible = contacts.isEmpty()
binding.recyclerView.isVisible = contacts.isNotEmpty()
adapter.submitList(contacts)
binding.tvCount.text = "${contacts.size} contacts"
}
}
}
// Trigger load — only if permission is already granted
if (ContextCompat.checkSelfPermission(
requireContext(), Manifest.permission.READ_CONTACTS
) == PackageManager.PERMISSION_GRANTED) {
viewModel.loadContacts()
}
}
}
Best Practices
- Always run on Dispatchers.IO — contact queries are disk I/O. Running on the main thread with a large contact list causes ANR (Application Not Responding) errors. Always use
withContext(Dispatchers.IO)inside aviewModelScopecoroutine. - Deduplicate by CONTACT_ID — a single person can have multiple raw contacts if they're linked across Google, WhatsApp, and other accounts. Using a
Map<Long, ContactDTO>keyed byCONTACT_IDmerges all their data into one entry automatically. - Use getColumnIndexOrThrow —
getColumnIndex()silently returns-1for missing columns.getColumnIndexOrThrow()fails fast with a clear error message. Always prefer it. - Use projection arrays — always pass a specific
projectionarray tocontentResolver.query(). Passingnullreturns all columns including ones you don't need, slowing down the query significantly on large contact lists. - Use cursor?.use { } — the
useblock automatically closes the cursor even if an exception is thrown. Never forget to close cursors — they are a common source of memory leaks in Android. - Filter empty contacts — the contacts database can contain system entries with no name or number. Always filter them out before displaying to avoid blank rows in your list.
Frequently Asked Questions
How do I read phone contacts in Android with Kotlin?
Request READ_CONTACTS permission using ActivityResultContracts.RequestPermission(). Then query ContactsContract.CommonDataKinds.Phone.CONTENT_URI using ContentResolver inside a coroutine on Dispatchers.IO. Use getColumnIndexOrThrow() to safely access columns and cursor.use{} to close automatically.
Why do I get duplicate contacts?
A single person can have multiple raw contacts linked from different accounts — Google, WhatsApp, phone storage. Deduplicate by using a Map keyed by CONTACT_ID. When you encounter the same ID again, add the new number to the existing entry instead of creating a new one.
How do I fetch email addresses along with contacts?
Query ContactsContract.Data.CONTENT_URI instead of the Phone URI. Check the MIMETYPE column — when it equals Email.CONTENT_ITEM_TYPE read DATA1 for the email. When it equals Phone.CONTENT_ITEM_TYPE read DATA1 for the phone number.
Why does contact syncing cause ANR?
ContentResolver queries run synchronously. On a device with thousands of contacts, the query can take several seconds. Running on the main thread blocks the UI causing ANR. Always use withContext(Dispatchers.IO) inside a viewModelScope coroutine.
- Request
READ_CONTACTSat runtime usingActivityResultContracts.RequestPermission() - Always run queries on
Dispatchers.IO— never on main thread - Deduplicate using a
Map<Long, ContactDTO>keyed byCONTACT_ID - Use
getColumnIndexOrThrow()— notgetColumnIndex() - Use
cursor.use{}— auto-closes cursor, prevents memory leaks - Always pass a projection array — null projection queries all columns unnecessarily
- For email + photo: query
ContactsContract.Data.CONTENT_URIand switch on MIME type - Filter out contacts with no name and no number before displaying