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:

NecessidadeAPI recomendadaPermissão ampla?
Usuário escolhe uma foto ou vídeoPhoto PickerNão
Usuário escolhe várias mídiasPhoto Picker múltiploNão
Usuário escolhe PDF, planilha ou qualquer arquivoOpenDocumentNão
App implementa uma galeria própria, listando biblioteca do aparelhoMediaStorePode exigir
App tira foto com experiência de câmera própriaCameraXCâmera
App só abre a câmera externa e recebe o resultadoTakePictureGeralmente 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:

  1. Consumir imediatamente: leia ou envie o Uri enquanto o fluxo está ativo.
  2. Copiar para armazenamento privado: opção mais previsível para upload posterior, edição ou retry.
  3. 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 é:

  1. usuário seleciona a mídia;
  2. app valida e copia para arquivo privado;
  3. banco salva um item PENDING com caminho interno e identificador;
  4. WorkManager agenda envio com restrição de rede;
  5. worker faz upload em streaming;
  6. backend valida e devolve o identificador final;
  7. app marca UPLOADED e 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

  1. Pedir READ_MEDIA_IMAGES para escolher avatar. O Photo Picker resolve sem acesso amplo.
  2. Tentar obter caminho físico do URI. Abra o conteúdo com ContentResolver.
  3. Carregar vídeo inteiro em memória. Faça streaming ou cópia em blocos.
  4. Guardar somente uri.toString() para upload futuro. A concessão pode não sobreviver; copie para área privada.
  5. Confiar na extensão .jpg. Consulte MIME e valide o arquivo no backend.
  6. Não limitar quantidade e tamanho. Uma seleção válida pode ser inviável para rede ou memória.
  7. Criar launcher no clique. Registre-o durante a composição.
  8. Disparar dois uploads do mesmo item. Use ID persistente e trabalho único.
  9. Manter temporários para sempre. Implemente limpeza após sucesso, cancelamento e timeout.
  10. Ignorar cancelamento. null ou lista vazia é resultado normal, não exceção.
  11. Usar o URI como chave de negócio. Gere um identificador próprio; provedores podem entregar representações diferentes.
  12. Logar dados da mídia. URI e nome podem conter informações pessoais.

Checklist de produção

  • activity-compose atualizado e centralizado no Version Catalog.
  • PickVisualMedia para seleção única e PickMultipleVisualMedia para múltipla.
  • Nenhuma permissão de galeria adicionada sem necessidade real.
  • Tipo de mídia explícito: imagem, vídeo ou ambos.
  • Uri tratado com ContentResolver, 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.