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.
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_CONTACTSunless you are building a Dialer or a Backup app. - Use
ActivityResultContracts.PickContact()to delegate selection to the OS. - Resolve the
CONTACT_IDexplicitly 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

🚀 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
lastPathSegmentas ID: Don't assume the URI ends with the ID. Always query the_IDcolumn for 100% reliability. - Querying
Phone.NUMBERfrom the Contact URI: The picker returns a Contact URI, not a Phone URI. You must perform a second query onCommonDataKinds.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_CONTACTSfrom 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.
- E-book (Best Value! 🚀): $1.99 on Google Play
- Kindle Edition: $3.49 on Amazon
- Also available in Paperback & Hardcover.

Comments
Post a Comment