Resposta rápida: para deixar o usuário selecionar fotos ou vídeos em um app Android moderno, use o Photo Picker com ActivityResultContracts.PickVisualMedia ou PickMultipleVisualMedia. Em Jetpack Compose, registre o contrato com rememberLauncherForActivityResult, escolha o tipo de mídia e trate o Uri devolvido. Esse fluxo normalmente não exige READ_MEDIA_IMAGES, READ_MEDIA_VIDEO nem a antiga READ_EXTERNAL_STORAGE, porque o usuário concede acesso apenas aos itens escolhidos. Depois, leia o conteúdo com ContentResolver, valide tipo e tamanho e, se precisar manter acesso por muito tempo, copie o arquivo para o armazenamento privado do app ou persista a permissão quando o contrato e o provedor permitirem.
O Photo Picker é a melhor opção quando o requisito é “escolher uma foto de perfil”, “anexar três imagens”, “selecionar um vídeo” ou “enviar mídia para o backend”. Ele oferece mais privacidade que uma galeria própria, reduz pedidos de permissão e evita várias diferenças de armazenamento entre versões do Android.
Este guia mostra seleção única e múltipla com Kotlin e Compose, preview, leitura segura do Uri, upload com WorkManager, fallback, testes e erros comuns. Se o app precisa capturar a imagem com câmera própria, veja CameraX com Kotlin e Compose. Para entender quando uma permissão ainda é necessária, consulte o guia de permissões no Android.
Photo Picker, seletor de documentos ou permissão de galeria?
As três soluções parecem equivalentes, mas atendem a necessidades diferentes:
| Necessidade | API recomendada | Permissão ampla? |
|---|---|---|
| Usuário escolhe uma foto ou vídeo | Photo Picker | Não |
| Usuário escolhe várias mídias | Photo Picker múltiplo | Não |
| Usuário escolhe PDF, planilha ou qualquer arquivo | OpenDocument | Não |
| App implementa uma galeria própria, listando biblioteca do aparelho | MediaStore | Pode exigir |
| App tira foto com experiência de câmera própria | CameraX | Câmera |
| App só abre a câmera externa e recebe o resultado | TakePicture | Geralmente não para câmera própria |
A regra prática é: se o usuário pode escolher explicitamente os itens, prefira o seletor do sistema. Pedir acesso a todas as fotos para enviar uma imagem de avatar é privilégio excessivo e aumenta a chance de abandono na tela de permissão.
O Photo Picker também reduz trabalho de compatibilidade. O contrato da Activity Result API usa o seletor adequado quando disponível e pode recorrer a mecanismos compatíveis do sistema em aparelhos sem a implementação principal. O app continua tratando um Uri, em vez de depender de um caminho físico no armazenamento.
Dependência e configuração
Use uma versão atual da biblioteca AndroidX Activity compatível com o projeto. Centralize a versão no Version Catalog:
# gradle/libs.versions.toml
[versions]
activity = "<versao-estavel-atual>"
[libraries]
androidx-activity-compose = {
module = "androidx.activity:activity-compose",
version.ref = "activity"
}
// build.gradle.kts do módulo app
dependencies {
implementation(libs.androidx.activity.compose)
}
O Photo Picker não faz parte do Compose BOM. O BOM alinha artefatos do Compose; activity-compose mantém uma versão própria.
Para o fluxo comum de seleção, não adicione permissões de leitura de imagens “por garantia”. Um manifesto mínimo pode não precisar de nenhuma permissão relacionada à galeria:
<manifest ...>
<!-- INTERNET somente se o app fizer upload para um backend -->
<uses-permission android:name="android.permission.INTERNET" />
<application ... />
</manifest>
Se o mesmo app possui uma galeria própria via MediaStore, trate esse recurso separadamente. Não use a necessidade de uma tela administrativa como justificativa para pedir acesso amplo no fluxo simples de avatar.
Selecionando uma foto no Jetpack Compose
O contrato PickVisualMedia devolve um Uri?. Valor null significa que o usuário cancelou ou não selecionou um item:
@Composable
fun PhotoPickerButton(
onPhotoSelected: (Uri) -> Unit,
) {
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia(),
) { uri: Uri? ->
uri?.let(onPhotoSelected)
}
Button(
onClick = {
launcher.launch(
PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageOnly
)
)
}
) {
Text("Escolher foto")
}
}
Não crie o launcher dentro do onClick. rememberLauncherForActivityResult deve ser chamado durante a composição e o clique apenas dispara launch.
Para selecionar somente vídeo:
launcher.launch(
PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.VideoOnly
)
)
Para aceitar imagem ou vídeo:
launcher.launch(
PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageAndVideo
)
)
Há também suporte a tipos MIME específicos por meio de SingleMimeType, quando a versão adotada da biblioteca oferece o comportamento necessário. Mesmo assim, valide o tipo recebido no app: filtros de UI ajudam a escolha, mas não substituem validação antes de processar ou enviar o conteúdo.
Mostrando preview com Coil
Bibliotecas de imagem para Android normalmente aceitam Uri diretamente. Com Coil em Compose:
@Composable
fun AvatarEditor() {
var selectedUri by rememberSaveable {
mutableStateOf<String?>(null)
}
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.PickVisualMedia(),
) { uri ->
selectedUri = uri?.toString()
}
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
AsyncImage(
model = selectedUri,
contentDescription = "Prévia da foto selecionada",
modifier = Modifier
.size(128.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop,
)
Button(
onClick = {
launcher.launch(
PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageOnly
)
)
}
) {
Text("Alterar foto")
}
}
}
Salvar uri.toString() ajuda a restaurar o estado visual após uma recriação simples da Activity, mas não garante acesso eterno ao conteúdo. A concessão pode ser temporária, o item pode ser removido pelo usuário e o processo pode voltar muito tempo depois. Para um avatar que será enviado imediatamente, mantenha o Uri só durante o fluxo. Para edição duradoura ou fila offline, copie o conteúdo para um arquivo privado do app.
Veja mais detalhes de preview, placeholder e tratamento de erro no guia de Coil com Jetpack Compose.
Selecionando várias fotos
Use PickMultipleVisualMedia e defina um limite coerente com o produto:
@Composable
fun MultiplePhotoPicker(
onPhotosSelected: (List<Uri>) -> Unit,
) {
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickMultipleVisualMedia(
maxItems = 5
),
) { uris ->
onPhotosSelected(uris)
}
OutlinedButton(
onClick = {
launcher.launch(
PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageOnly
)
)
}
) {
Text("Selecionar até 5 fotos")
}
}
O limite não deve existir apenas na UI. O ViewModel ou caso de uso também precisa rejeitar quantidade acima da regra, especialmente se outras entradas puderem acionar o upload.
Antes de escolher um número alto, considere memória, tempo de envio e experiência em rede móvel. Para uma avaliação de produto, 5 imagens podem bastar; para um chat, talvez o limite seja 10; para backup de galeria, o Photo Picker pontual provavelmente não é a arquitetura adequada.
Um estado de domínio simples evita passar listas soltas por toda a UI:
data class MediaSelectionState(
val items: List<SelectedMedia> = emptyList(),
val isUploading: Boolean = false,
val error: String? = null,
)
data class SelectedMedia(
val uri: Uri,
val mimeType: String?,
val sizeBytes: Long?,
)
Nunca transforme o Uri em “caminho real”
Um dos erros mais persistentes em Android é procurar uma função getRealPathFromUri(). Em armazenamento moderno, o Uri pode representar conteúdo local, nuvem, provedor de documentos ou mídia gerenciada pelo sistema. Um caminho de arquivo pode não existir ou não estar acessível ao app.
Use ContentResolver:
fun readBytes(
context: Context,
uri: Uri,
): ByteArray {
return context.contentResolver
.openInputStream(uri)
?.use(InputStream::readBytes)
?: error("Não foi possível abrir a mídia selecionada")
}
Esse exemplo é simples, mas carregar uma foto ou vídeo inteiro em ByteArray pode estourar memória. Para upload, prefira streaming. Para transformação de imagem, decodifique com amostragem e limites. Para arquivos grandes, copie em blocos:
fun copyToPrivateStorage(
context: Context,
uri: Uri,
destination: File,
) {
context.contentResolver.openInputStream(uri).use { input ->
requireNotNull(input) { "Conteúdo indisponível" }
destination.outputStream().use { output ->
input.copyTo(output, bufferSize = 8 * 1024)
}
}
}
O arquivo copiado para filesDir ou cacheDir passa a ser controlado pelo app. Use filesDir quando ele precisa sobreviver por mais tempo e cacheDir quando pode ser removido e recriado. Crie uma rotina de limpeza; mídia temporária esquecida vira consumo invisível de armazenamento.
Descobrindo tipo, nome e tamanho
Não confie na extensão exibida. Consulte metadados e imponha limites:
data class MediaMetadata(
val displayName: String?,
val mimeType: String?,
val sizeBytes: Long?,
)
fun queryMetadata(context: Context, uri: Uri): MediaMetadata {
val resolver = context.contentResolver
val mimeType = resolver.getType(uri)
val projection = arrayOf(
OpenableColumns.DISPLAY_NAME,
OpenableColumns.SIZE,
)
resolver.query(uri, projection, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
return MediaMetadata(
displayName = nameIndex.takeIf { it >= 0 }
?.let(cursor::getString),
mimeType = mimeType,
sizeBytes = sizeIndex.takeIf { it >= 0 && !cursor.isNull(it) }
?.let(cursor::getLong),
)
}
}
return MediaMetadata(
displayName = null,
mimeType = mimeType,
sizeBytes = null,
)
}
Nem todo provedor informa tamanho. Quando sizeBytes vier nulo, aplique um limite durante a leitura: conte bytes e interrompa ao ultrapassar o máximo. Valide novamente no backend, porque qualquer regra apenas no cliente pode ser contornada.
Exemplo de validação local:
fun validateImage(metadata: MediaMetadata): Result<Unit> = runCatching {
require(metadata.mimeType in setOf("image/jpeg", "image/png", "image/webp")) {
"Formato não suportado"
}
metadata.sizeBytes?.let { size ->
require(size <= 10L * 1024 * 1024) {
"A imagem deve ter no máximo 10 MB"
}
}
}
O backend deve verificar MIME real, tamanho, dimensões e conteúdo antes de publicar. Nome e Content-Type enviados pelo cliente não são prova de que o arquivo é uma imagem válida.
Mantendo acesso depois que o app fecha
Há três estratégias, em ordem de simplicidade:
- Consumir imediatamente: leia ou envie o
Urienquanto o fluxo está ativo. - Copiar para armazenamento privado: opção mais previsível para upload posterior, edição ou retry.
- Persistir a permissão do URI: útil em fluxos baseados em documentos quando as flags e o provedor permitem; não presuma que todo resultado do Photo Picker oferece a mesma concessão persistente.
Para uma fila de upload confiável, copiar para uma área privada costuma ser a escolha mais fácil de testar. O ViewModel registra o arquivo local; o worker envia; após sucesso, uma rotina remove a cópia.
Não guarde apenas uma string de URI no banco e espere que ela funcione semanas depois. Teste explicitamente reinicialização do processo, reinício do aparelho e remoção da mídia original.
Upload em background com WorkManager
Uploads que precisam sobreviver à saída da tela devem ir para o WorkManager. O fluxo recomendado é:
- usuário seleciona a mídia;
- app valida e copia para arquivo privado;
- banco salva um item
PENDINGcom caminho interno e identificador; - WorkManager agenda envio com restrição de rede;
- worker faz upload em streaming;
- backend valida e devolve o identificador final;
- app marca
UPLOADEDe remove o temporário quando seguro.
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = OneTimeWorkRequestBuilder<MediaUploadWorker>()
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
30,
TimeUnit.SECONDS,
)
.setInputData(
workDataOf("media_id" to mediaId)
)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"media-upload-$mediaId",
ExistingWorkPolicy.KEEP,
request,
)
Passe um ID pequeno no Data, não bytes, bitmap nem URI sem garantia de acesso. O worker consulta o banco e abre o arquivo privado. Use trabalho único para evitar duas tentativas simultâneas do mesmo upload.
O guia de WorkManager com Kotlin aprofunda retry, constraints e observabilidade do trabalho.
ViewModel e fluxo de estado
O launcher pertence à camada de UI, porque depende do mecanismo de Activity Result. O processamento pertence ao ViewModel ou a um caso de uso:
class ProfilePhotoViewModel(
private val mediaImporter: MediaImporter,
) : ViewModel() {
private val _state = MutableStateFlow(ProfilePhotoState())
val state = _state.asStateFlow()
fun onPhotoSelected(uri: Uri) {
viewModelScope.launch {
_state.update { it.copy(importing = true, error = null) }
runCatching {
mediaImporter.importImage(uri)
}.onSuccess { localMedia ->
_state.update {
it.copy(
importing = false,
previewPath = localMedia.path,
)
}
}.onFailure { error ->
_state.update {
it.copy(
importing = false,
error = error.message ?: "Não foi possível importar a foto",
)
}
}
}
}
}
Não passe Context arbitrário para o ViewModel. Encapsule ContentResolver em uma implementação Android de MediaImporter, injetada por interface. Isso mantém validação e regras testáveis.
Compatibilidade e fallback
Antes de criar uma solução manual, deixe a Activity Result API cuidar do caminho compatível suportado pela versão adotada. Se o produto precisa mudar a interface conforme disponibilidade, consulte a API de disponibilidade do contrato:
val available = ActivityResultContracts.PickVisualMedia
.isPhotoPickerAvailable(context)
A ausência do Photo Picker principal não significa automaticamente que o app deve pedir acesso a toda a galeria. Para seleção pontual, OpenDocument ou o fallback do próprio contrato continuam sendo opções mais privadas.
Teste em mais de uma combinação:
- Android recente com Photo Picker nativo;
- aparelho ou emulador com implementação compatível/backport;
- versão antiga que usa fallback;
- provedor de nuvem;
- usuário cancelando sem seleção;
- mídia removida entre seleção e leitura.
Evite condicionar toda a lógica a Build.VERSION.SDK_INT se a biblioteca já expõe disponibilidade. Recursos distribuídos por componentes do sistema nem sempre são descritos apenas pela versão da API.
Android 14+ e acesso parcial a fotos
Versões recentes do Android permitem que o usuário conceda acesso apenas a fotos selecionadas quando um app solicita permissões de biblioteca. Esse fluxo é importante para apps que realmente implementam uma galeria própria via MediaStore.
Ele não transforma a permissão ampla na primeira escolha para anexos. Se o usuário só precisa escolher dois itens, o Photo Picker já oferece seleção explícita sem obrigar o app a manter uma tela de galeria, sincronizar mudanças de permissão ou explicar por que deseja listar toda a biblioteca.
Use a seguinte decisão:
- Anexo/avatar/upload pontual: Photo Picker.
- Galeria própria ou editor que navega repetidamente por toda a biblioteca: MediaStore + modelo de permissões aplicável.
- Documento que não é mídia visual:
OpenDocument.
Vídeos, duração e reprodução
Ao aceitar vídeos, tamanho não é a única regra. Valide duração, codec e resolução conforme o backend. Um vídeo 4K curto pode ser maior que o limite; um arquivo pequeno pode usar um formato não suportado pelo pipeline.
Para preview e reprodução, passe o Uri ao AndroidX Media3:
val mediaItem = MediaItem.fromUri(selectedUri)
player.setMediaItem(mediaItem)
player.prepare()
player.playWhenReady = true
O guia de Media3 ExoPlayer com Compose mostra lifecycle, PlayerView, cache e liberação do player. Não use o Photo Picker para “converter” vídeo: ele seleciona conteúdo; transcodificação e upload são responsabilidades separadas.
Segurança e privacidade
Mesmo sem pedir acesso amplo, o app ainda processa conteúdo do usuário. Adote estas práticas:
- colete apenas a mídia necessária ao recurso;
- explique antes da seleção como a foto ou o vídeo será usado;
- não registre URI completo, nome de arquivo ou imagem em analytics;
- remova cópias temporárias após upload, cancelamento ou expiração;
- valide conteúdo no cliente para UX e novamente no servidor para segurança;
- gere nomes internos aleatórios em vez de confiar no nome original;
- limite bytes, dimensões, duração e quantidade;
- remova metadados EXIF sensíveis quando o produto não precisa deles;
- não publique automaticamente uma imagem apenas porque o upload terminou;
- trate falha parcial ao enviar vários itens.
Se a imagem for usada como avatar público, mostre confirmação e política de remoção. Se houver moderação, modele estados como PENDING, APPROVED e REJECTED, em vez de trocar a URL pública imediatamente.
Testes
Separe o teste em camadas.
Teste unitário
Teste regras sem depender do seletor do sistema:
- MIME permitido e proibido;
- limite de tamanho conhecido e desconhecido;
- quantidade máxima;
- cópia interrompida;
- retry idempotente;
- limpeza após upload.
Use um MediaImporter fake que devolve um arquivo de teste ou falha de forma controlada.
Teste de UI e integração
O Photo Picker é UI do sistema, então não baseie toda a suíte em encontrar textos específicos do fabricante. Valide principalmente:
- botão dispara o launcher;
- retorno com URI atualiza a tela;
- cancelamento mantém estado anterior;
- erro de leitura gera mensagem acessível;
- rotação não agenda upload duplicado;
- seleção múltipla respeita limite;
- processo recriado recupera item já copiado.
Em testes instrumentados, você pode abstrair o disparo por uma interface ou testar a tela com callback injetável. Deixe poucos testes end-to-end reais para o seletor e concentre regras no domínio.
Erros comuns
- Pedir
READ_MEDIA_IMAGESpara escolher avatar. O Photo Picker resolve sem acesso amplo. - Tentar obter caminho físico do URI. Abra o conteúdo com
ContentResolver. - Carregar vídeo inteiro em memória. Faça streaming ou cópia em blocos.
- Guardar somente
uri.toString()para upload futuro. A concessão pode não sobreviver; copie para área privada. - Confiar na extensão
.jpg. Consulte MIME e valide o arquivo no backend. - Não limitar quantidade e tamanho. Uma seleção válida pode ser inviável para rede ou memória.
- Criar launcher no clique. Registre-o durante a composição.
- Disparar dois uploads do mesmo item. Use ID persistente e trabalho único.
- Manter temporários para sempre. Implemente limpeza após sucesso, cancelamento e timeout.
- Ignorar cancelamento.
nullou lista vazia é resultado normal, não exceção. - Usar o URI como chave de negócio. Gere um identificador próprio; provedores podem entregar representações diferentes.
- Logar dados da mídia. URI e nome podem conter informações pessoais.
Checklist de produção
activity-composeatualizado e centralizado no Version Catalog.PickVisualMediapara seleção única ePickMultipleVisualMediapara múltipla.- Nenhuma permissão de galeria adicionada sem necessidade real.
- Tipo de mídia explícito: imagem, vídeo ou ambos.
Uritratado comContentResolver, sem “real path”.- Limites de quantidade, bytes, dimensões e duração documentados.
- Preview com estado de loading e erro.
- Cópia privada antes de upload adiado.
- WorkManager usa ID persistido, constraint de rede e trabalho único.
- Backend revalida conteúdo e não confia em MIME do cliente.
- Temporários possuem política de limpeza.
- Cancelamento, processo recriado e mídia indisponível foram testados.
- Acessibilidade: botão rotulado e preview com descrição adequada ou decorativa.
Perguntas frequentes
Photo Picker precisa de permissão de armazenamento?
Para o fluxo normal de seleção explícita, não. O sistema concede acesso aos itens escolhidos. Permissões de mídia são necessárias em outros cenários, como uma galeria própria que consulta a biblioteca pelo MediaStore.
Como selecionar várias fotos no Compose?
Registre ActivityResultContracts.PickMultipleVisualMedia(maxItems) com rememberLauncherForActivityResult e lance um PickVisualMediaRequest(ImageOnly). O callback recebe List<Uri>.
Posso salvar o URI no Room e abrir depois?
Você pode salvar a string, mas precisa garantir que a concessão de leitura continuará válida. Para upload posterior confiável, copie a mídia para armazenamento privado e salve o identificador do arquivo importado.
Como enviar a imagem para uma API?
Abra um InputStream pelo ContentResolver e envie como corpo multipart em streaming, ou copie antes para um arquivo privado e deixe o WorkManager fazer o upload. Não converta arquivos grandes em ByteArray sem limite.
Photo Picker substitui CameraX?
Não. Photo Picker seleciona mídia existente; CameraX controla captura, preview e análise da câmera. Um produto pode usar ambos: “Escolher da galeria” com Photo Picker e “Tirar foto” com CameraX ou TakePicture.
O que fazer quando o usuário cancela?
Mantenha o estado anterior e não mostre erro. Cancelar é uma decisão normal. Só remova a foto atual se houver uma ação separada e explícita de remoção.
Conclusão
O Photo Picker no Android com Kotlin e Jetpack Compose é o padrão certo para seleção pontual de fotos e vídeos: menos permissões, menos diferenças entre versões e uma experiência mais previsível para o usuário. A implementação básica é curta — rememberLauncherForActivityResult, PickVisualMediaRequest e um Uri —, mas uma solução de produção também precisa validar conteúdo, evitar caminhos físicos, controlar memória e preparar acesso duradouro quando houver upload em background.
A decisão arquitetural mais importante é distinguir seleção de biblioteca. Se o app pede ao usuário um anexo, use o seletor do sistema. Se precisa navegar por toda a galeria, aí sim avalie MediaStore e permissões. Esse recorte reduz risco de privacidade e simplifica manutenção.
Para continuar, leia permissões modernas no Android, CameraX com Compose, WorkManager e offline-first com Kotlin. Quem quer aplicar essa stack em produtos reais pode acompanhar as vagas Kotlin e Android atualizadas no Kotlin Brasil.