Skip to content
All posts

The Android Photo Picker API in 2026: letting users attach a photo without granting gallery access

A practical guide to Android's Photo Picker API — single and multi-select, MIME filtering, the pre-Android-13 backport, and why it beats READ_MEDIA_IMAGES for privacy and approval.

MFKAPPS 4 min read

Ask a user for permission to see their entire photo library so they can attach one receipt to one expense, and you’ve asked for far more than the feature needs. READ_MEDIA_IMAGES grants your app standing access to every photo on the device — vacation pictures, screenshots of other people’s messages, whatever’s in there — for a feature that only ever needed the one image the user picked. That mismatch is exactly what Android’s Photo Picker was built to close.

I added it to Granyn to let a user attach a photo of a receipt to a logged expense. Here’s how the API actually works, and why it made the permission request disappear entirely.

The old way, and why it’s a liability

Before the Photo Picker, “let the user choose a photo” meant one of two paths: request READ_MEDIA_IMAGES (or READ_EXTERNAL_STORAGE pre-Android 13) and query MediaStore yourself, or launch ACTION_GET_CONTENT and hope the system’s document picker behaved consistently across OEM skins. The permission path works, but it costs you:

  • A runtime permission prompt for a feature that’s easy to decline out of habit, which quietly kills the feature for a chunk of users.
  • Play Console’s Sensitive Permissions declaration flow — broad media access invites scrutiny during review.
  • Standing access you have to justify in your privacy policy, even though your app only ever touches the one photo the user selected.

None of that buys the user anything. The feature only needs read access to a single image, once.

How the Photo Picker actually works

The Photo Picker is a system-owned UI — it runs in a separate, trusted process, and the image the user taps is handed to your app as a content:// URI. Your app never gets a permission grant to the gallery; it gets exactly the one URI, scoped to that photo, for as long as you need it.

Setup needs no manifest permission at all for a single pick:

class AddExpenseFragment : Fragment() {

    private val pickImage = registerForActivityResult(
        ActivityResultContracts.PickVisualMedia(),
    ) { uri: Uri? ->
        if (uri != null) attachReceiptPhoto(uri)
    }

    private fun launchPicker() {
        pickImage.launch(
            PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
        )
    }
}

ActivityResultContracts.PickVisualMedia.ImageOnly filters the picker to images; VideoOnly and SingleMimeType("image/png") cover the other common cases. No <uses-permission> entry, no runtime prompt, no rationale dialog to write copy for.

Picking more than one photo

A receipt is usually one photo, but the same contract has a multi-select variant, capped at a count you choose:

private val pickImages = registerForActivityResult(
    ActivityResultContracts.PickMultipleVisualMedia(maxItems = 5),
) { uris: List<Uri> ->
    uris.forEach { attachReceiptPhoto(it) }
}

The system UI enforces the cap itself — it disables further taps once the user hits the limit, rather than your code needing to reject a longer list after the fact.

The URI you get back isn’t permanent

This is the detail that trips people up: the content:// URI handed to your ActivityResultCallback is only guaranteed readable for the duration of that call. If you store the URI string in your database and try to open it days later, it can throw a SecurityException — the grant doesn’t outlive the picker session the way a persisted Storage Access Framework URI does.

The fix is to copy the bytes you need immediately, into a file your app owns:

private fun attachReceiptPhoto(sourceUri: Uri) {
    val destFile = File(requireContext().filesDir, "receipts/${UUID.randomUUID()}.jpg")
    destFile.parentFile?.mkdirs()

    requireContext().contentResolver.openInputStream(sourceUri)?.use { input ->
        destFile.outputStream().use { output -> input.copyTo(output) }
    }

    viewModel.setReceiptPath(destFile.absolutePath)
}

Do the copy off the main thread — it’s file I/O — but do it before the picker’s Activity result callback returns, not on a later screen. Once you’ve copied the bytes, the transient content:// grant no longer matters; your app owns a private file it can read for as long as it needs to.

The pre-Android-13 backport

The Photo Picker shipped as a system feature in Android 13, but it’s backported to API 21+ through Google Play services — the same ActivityResultContracts.PickVisualMedia call resolves to the backport automatically on older devices with Play services installed. There’s one thing worth checking explicitly if your minimum feature set genuinely requires the native picker’s behavior (like per-app cloud media provider integration):

val isPhotoPickerAvailable = ActivityResultContracts.PickVisualMedia
    .isPhotoPickerAvailable(requireContext())

In practice you don’t need to branch on this for a simple attach-a-photo flow — the contract degrades gracefully on its own. It matters if you’re deciding whether to show picker-specific UI hints that only make sense on the native version.

What actually mattered

The win here isn’t a new gesture or a prettier picker UI — it’s that the permission model finally matches what the feature does. A user attaching one receipt photo should never have granted an app standing access to their whole camera roll, and until the Photo Picker existed, Android didn’t have a clean way to avoid that. Swapping READ_MEDIA_IMAGES for PickVisualMedia removed a manifest permission, a runtime prompt, and a paragraph of my privacy policy — and the feature works exactly the same from the user’s side, just without asking for more than it needs.