Stop Using READ_CONTACTS: A Modern Android Contact Picker Guide

 How to migrate to a zero-permission strategy, boost user trust, and write production-grade Kotlin code for the system contact picker.

Stop Using READ_CONTACTS: A Modern Android Contact Picker Guide

For over a decade, the standard for obtaining a phone number on Android was to add READ_CONTACTS to your Manifest, request a "Dangerous Permission," and query the entire database.

But the era of broad data harvesting is over. Modern Android permissions best practices favor system-provided pickers. Just as the Photo Picker replaced broad storage access, using the Android contact picker allows your app to get exactly what it needs — and nothing more.

⚡ TL;DR

  • Avoid READ_CONTACTS unless you are building a Dialer or a Backup app.
  • Use ActivityResultContracts.PickContact() to delegate selection to the OS.
  • Resolve the CONTACT_ID explicitly from the returned URI before querying.
  • Cache data immediately, as URI access is short-lived and not guaranteed to persist.

🧠 Why This Matters for Modern Android

Android is steadily moving toward privacy-first APIs. Users expect transparency, and the Google Play Store increasingly enforces it. Shifting to an Android contact picker instead of broad permissions helps:

  • Reduce Friction: Users don’t have to approve “scary” permission dialogs during install or onboarding.
  • Improve Trust: Users feel in control because they choose exactly which contact to share.
  • Lower Review Risks: Apps with fewer “Dangerous Permissions” often face smoother Play Store reviews.
  • Simplify Code: You no longer need to handle complex permission request results and “denied” states.

📊 READ_CONTACTS vs. Contact Picker

READ_CONTACTS vs. Contact Picker
READ_CONTACTS vs. Contact Picker

🚀 Implementation: The Robust Way (Kotlin)

To ensure your app works across different device manufacturers (OEMs), you must resolve the internal _ID from the Picker's URI before accessing specific data tables.

class InviteActivity : AppCompatActivity() {

// 1. Register the contract. No 'Dangerous Permission' required!
private val contactPickerLauncher = registerForActivityResult(
ActivityResultContracts.PickContact()
) { contactUri: Uri? ->
contactUri?.let { handleSelection(it) }
}

private fun handleSelection(contactUri: Uri) {
// 2. Safely resolve the internal Contact ID
val contactId = contentResolver.query(contactUri, arrayOf(ContactsContract.Contacts._ID), null, null, null)?.use { cursor ->
val idIndex = cursor.getColumnIndex(ContactsContract.Contacts._ID)
if (idIndex != -1 && cursor.moveToFirst()) cursor.getString(idIndex) else null
}

contactId?.let { id ->
// 3. Query for phone numbers associated with this ID
val projection = arrayOf(
ContactsContract.CommonDataKinds.Phone.NUMBER,
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME
)

contentResolver.query(
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
projection,
"${ContactsContract.CommonDataKinds.Phone.CONTACT_ID} = ?",
arrayOf(id),
null
)?.use { cursor ->
val numberIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
val nameIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)

while (cursor.moveToNext()) {
val number = if (numberIndex != -1) cursor.getString(numberIndex) else null
val name = if (nameIndex != -1) cursor.getString(nameIndex) else "Unknown"

// 4. Guard against empty numbers (common in some OEM contacts)
if (!number.isNullOrEmpty()) {
Log.d("ContactPicker", "Found: $name, Number: $number")
// Cache data locally here!
}
}
}
}
}
}

⚠️ Common Mistakes to Avoid

  • Using lastPathSegment as ID: Don't assume the URI ends with the ID. Always query the _ID column for 100% reliability.
  • Querying Phone.NUMBER from the Contact URI: The picker returns a Contact URI, not a Phone URI. You must perform a second query on CommonDataKinds.Phone.CONTENT_URI.
  • Ignoring Multiple Numbers: If a user has “Work” and “Personal” numbers, your query will return multiple rows. Handle this with a selection dialog.
  • Assuming Permanent Access: The system grants temporary read access. Cache the name and number immediately if you need them after the current app session.

🧩 When You Still Need READ_CONTACTS

The picker is a great READ_CONTACTS alternative, but it isn’t a silver bullet. You still need broad permissions if your app:

  • Syncs Contacts: (e.g., Cloud backup or CRM services).
  • Full Listing: (e.g., A custom Dialer or SMS app).
  • Background Processing: If you need to scan or monitor contact changes without user interaction.

⚡ Developer FAQ

Can I filter the picker to show only phone numbers?

Yes. Use a custom Intent with type = ContactsContract.CommonDataKinds.Phone.CONTENT_TYPE.

Is this backward compatible?

Absolutely. ActivityResultContracts.PickContact() is a modern wrapper for Intent.ACTION_PICK, which works on virtually every Android version in use today.

What about selecting multiple contacts?

Android does not currently provide a native “Multi-Contact Picker” intent. If your app requires bulk selection, you may still need to request the broad permission — be sure to justify this use case to the Play Store.

🚀 The Challenge: Audit Your Manifest

Check your AndroidManifest.xml today. If you see READ_CONTACTS, ask yourself: "Do I need the whole book, or just a page?" Moving to a picker-based strategy reduces your security surface area and builds immediate trust with your users.

Learn more from the official Android Contacts Provider documentation on developer.android.com.

👇 Let’s discuss in the comments:

  • Have you removed READ_CONTACTS from your app yet?
  • Did switching to the Android contact picker improve your user retention or UX?
  • Would you still choose permissions over the picker in any specific scenario?

📘 Master Your Next Technical Interview

Since Java is the foundation of Android development, mastering DSA is essential. I highly recommend “Mastering Data Structures & Algorithms in Java”. It’s a focused roadmap covering 100+ coding challenges to help you ace your technical rounds.


Comments

Popular posts from this blog

No More _state + state: Simplifying ViewModels with Kotlin 2.3

Why You Should Stop Passing ViewModels Around Your Compose UI Tree 🚫

Is Jetpack Compose Making Your APK Fatter? (And How to Fix It)