Android Photo Picker API в 2026 году: прикрепить фото без доступа ко всей галерее
Практическое руководство по Photo Picker API в Android — одиночный и множественный выбор, фильтрация по MIME, бэкпорт для версий до Android 13 и почему это лучше READ_MEDIA_IMAGES.
Просить у пользователя разрешение видеть всю его фототеку только для того, чтобы он мог прикрепить фото чека к одной трате, — значит просить намного больше, чем на самом деле нужно функции. READ_MEDIA_IMAGES даёт приложению постоянный доступ к каждой фотографии на устройстве — снимкам с отпуска, скриншотам чужих переписок, всему подряд — ради функции, которой на деле нужно было только одно изображение, выбранное пользователем. Именно этот разрыв и должен был устранить Photo Picker в Android.
Я добавил его в Granyn, чтобы пользователь мог прикрепить фото чека к записанной трате. Ниже — как этот API устроен на самом деле и почему он полностью убрал запрос разрешения.
Старый способ и почему он проблематичен
До появления Photo Picker «дать пользователю выбрать фото» означало один из двух путей: запросить READ_MEDIA_IMAGES (или READ_EXTERNAL_STORAGE до Android 13) и самостоятельно опрашивать MediaStore, либо запускать ACTION_GET_CONTENT и надеяться, что системный выбор документов будет вести себя одинаково на разных прошивках производителей. Путь через разрешение работает, но у него есть цена:
- Запрос разрешения во время выполнения для функции, которую легко отклонить по привычке, — это незаметно убивает саму функцию для части пользователей.
- Поток декларирования «чувствительных разрешений» в Play Console — широкий доступ к медиа привлекает больше внимания при проверке.
- Постоянный доступ, который приходится оправдывать в политике конфиденциальности, даже если приложение реально касается только одной фотографии, выбранной пользователем.
Ничего из этого не даёт пользователю ничего взамен. Функции нужен лишь доступ на чтение к одному изображению, один раз.
Как на самом деле работает Photo Picker
Photo Picker — это интерфейс, принадлежащий системе: он работает в отдельном доверенном процессе, а изображение, на которое нажал пользователь, передаётся приложению в виде URI content://. Приложение никогда не получает разрешение на доступ к галерее — оно получает ровно этот один URI, ограниченный этой фотографией, на всё время, пока он нужен.
Для одиночного выбора настройка вообще не требует разрешения в манифесте:
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 ограничивает выбор изображениями; VideoOnly и SingleMimeType("image/png") покрывают остальные типичные случаи. Никакой записи <uses-permission>, никакого запроса во время выполнения, никакого диалога с обоснованием, текст для которого нужно писать.
Выбор нескольких фото
Обычно чек — это одно фото, но у того же contract есть вариант с множественным выбором, с ограничением, которое вы задаёте сами:
private val pickImages = registerForActivityResult(
ActivityResultContracts.PickMultipleVisualMedia(maxItems = 5),
) { uris: List<Uri> ->
uris.forEach { attachReceiptPhoto(it) }
}
Системный интерфейс сам применяет это ограничение — он блокирует дальнейшие нажатия, как только пользователь достигает лимита, так что вашему коду не нужно потом отклонять слишком длинный список.
Полученный URI не постоянен
Вот деталь, на которой многие спотыкаются: URI content://, переданный в ваш ActivityResultCallback, гарантированно читаем только на протяжении этого вызова. Если сохранить строку URI в базе данных и попытаться открыть её через несколько дней, может вылететь SecurityException — в отличие от постоянного URI Storage Access Framework, это разрешение не переживает сессию выбора фото.
Решение — сразу же скопировать нужные байты в файл, принадлежащий вашему приложению:
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)
}
Выполняйте копирование не в главном потоке — это файловый ввод-вывод, — но сделайте это до того, как завершится колбэк результата Activity выбора фото, а не на следующем экране. После копирования байтов временное разрешение content:// уже не важно: у приложения теперь есть собственный приватный файл, который можно читать сколько угодно долго.
Бэкпорт для версий до Android 13
Photo Picker появился как системная функция в Android 13, но благодаря Google Play services бэкпортирован вплоть до API 21+ — тот же вызов ActivityResultContracts.PickVisualMedia автоматически разрешается в этот бэкпорт на старых устройствах с установленными Play services. Есть один момент, который стоит проверить явно, если ваш минимальный набор функций действительно требует поведения нативного выбора (например, интеграции облачных медиа-провайдеров для конкретного приложения):
val isPhotoPickerAvailable = ActivityResultContracts.PickVisualMedia
.isPhotoPickerAvailable(requireContext())
На практике для простого сценария «прикрепить одно фото» ветвление по этому флагу не нужно — contract сам корректно деградирует. Это важно, когда вы решаете, показывать ли подсказки интерфейса, специфичные для выбора фото, которые имеют смысл только в нативной версии.
Что оказалось действительно важным
Выигрыш здесь не в новом жесте и не в более симпатичном интерфейсе выбора — а в том, что модель разрешений наконец соответствует тому, что функция реально делает. Пользователь, прикрепляющий фото одного чека, никогда не должен был давать постоянный доступ ко всей своей фотоплёнке, и пока не появился Photo Picker, у Android не было чистого способа этого избежать. Замена READ_MEDIA_IMAGES на PickVisualMedia убрала одно разрешение из манифеста, один запрос во время выполнения и один абзац из моей политики конфиденциальности — а функция работает для пользователя ровно так же, просто больше не просит лишнего.
// По теме
Ещё из журнала
Биометрическая аутентификация в Android в 2026 году: BiometricPrompt, Keystore и блокировка local-first приложения
Практическое руководство по androidx.biometric в Android — BiometricPrompt, ключи на основе CryptoObject, резервный вариант с учётными данными устройства и ошибки, из-за которых проверка отпечатка не защищает ничего.
Health Connect в Android в 2026 году: как читать и писать данные, не отказываясь от local-first
Практическое руководство по API Health Connect в Android в 2026 году — разрешения, фоновое чтение и почему local-first-приложениям стоит относиться к нему как к опциональному источнику, а не как к истине.
androidx.startup в 2026 году: порядок инициализации библиотек без стопки ContentProvider'ов
Практическое руководство по библиотеке App Startup для Android — объединение initializer'ов в один ContentProvider, объявление зависимостей и случаи ленивой инициализации, которые она не заменяет.