Android Contact Syncing with Kotlin - Read, Filter and Display Phone Contacts

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.

Android contact syncing with Kotlin

Photo by Melinda Gimpel on Unsplash

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()
        }
}
💡 Why getColumnIndexOrThrow instead of getColumnIndex?
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 a viewModelScope coroutine.
  • 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 by CONTACT_ID merges all their data into one entry automatically.
  • Use getColumnIndexOrThrowgetColumnIndex() silently returns -1 for missing columns. getColumnIndexOrThrow() fails fast with a clear error message. Always prefer it.
  • Use projection arrays — always pass a specific projection array to contentResolver.query(). Passing null returns all columns including ones you don't need, slowing down the query significantly on large contact lists.
  • Use cursor?.use { } — the use block 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.

📚 Continue Learning
📝 Summary
  • Request READ_CONTACTS at runtime using ActivityResultContracts.RequestPermission()
  • Always run queries on Dispatchers.IO — never on main thread
  • Deduplicate using a Map<Long, ContactDTO> keyed by CONTACT_ID
  • Use getColumnIndexOrThrow() — not getColumnIndex()
  • 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_URI and switch on MIME type
  • Filter out contacts with no name and no number before displaying

Pragnesh Ghoda

A forward-thinking developer offering more than 8 years of experience building, integrating, and supporting android applications for mobile and tablet devices on the Android platform. Talks about #kotlin and #android

Post a Comment

Please let us know about any concerns or query.

Previous Post Next Post

Contact Form