Resposta rápida: para abrir PDF, planilha, ZIP ou qualquer documento no Android moderno sem pedir acesso amplo ao armazenamento, use o Storage Access Framework (SAF) com ActivityResultContracts.OpenDocument, CreateDocument ou OpenDocumentTree. Em Jetpack Compose, registre o contrato com rememberLauncherForActivityResult, receba um Uri e leia/escreva via ContentResolver. Quando precisar navegar em uma árvore (pasta escolhida pelo usuário), use DocumentFile.fromTreeUri. Persista a concessão com contentResolver.takePersistableUriPermission se o acesso precisa sobreviver a reinícios. SAF não substitui Photo Picker para fotos/vídeos pontuais nem MediaStore para galeria própria — ele é a API certa para documentos e pastas escolhidos pelo usuário.
Em apps brasileiros de campo, financeiro, RH e logística, o padrão “anexar comprovante”, “exportar relatório” ou “escolher pasta de backup” ainda aparece com READ_EXTERNAL_STORAGE, caminhos /sdcard/... e File direto. Em 2026 isso quebra em aparelhos com scoped storage, gera rejeição de privacidade e falha em provedores de nuvem (Drive, OneDrive, Downloads do sistema). Este guia fecha a lacuna: quando usar SAF, como implementar com Kotlin e Compose, como persistir permissões e como evitar os erros que mais aparecem em code review.
Quando usar SAF (e quando não usar)
Faça a pergunta: o usuário escolhe explicitamente o arquivo ou a pasta?
| Necessidade | API recomendada | Permissão ampla? |
|---|---|---|
| Anexar PDF, XML, CSV, ZIP, DOCX | OpenDocument | Não |
| Exportar/salvar um arquivo gerado pelo app | CreateDocument | Não |
| Backup/export contínuo em pasta escolhida | OpenDocumentTree + DocumentFile | Não |
| Escolher foto/vídeo pontual | Photo Picker | Não |
| Galeria própria listando a biblioteca | MediaStore | Pode exigir |
| Arquivos privados só do app | filesDir / cacheDir | Não |
| Download grande gerenciado pelo sistema | DownloadManager | Depende do destino |
A regra prática: se o usuário aponta o documento, use SAF. Se o app precisa varrer o armazenamento inteiro “porque talvez tenha um PDF”, redesenhe o fluxo. Pedir acesso amplo para anexar um comprovante é o mesmo antipadrão de pedir READ_MEDIA_IMAGES para avatar.
SAF também é o caminho correto quando o conteúdo pode estar em provedores remotos. O Uri devolvido pode apontar para nuvem; o app não deve assumir caminho local nem File.exists().
Dependências e contratos
Os contratos principais estão em AndroidX Activity. No Version Catalog:
# gradle/libs.versions.toml
[versions]
activity = "<versao-estavel-atual>"
documentfile = "<versao-estavel-atual>"
[libraries]
androidx-activity-compose = {
module = "androidx.activity:activity-compose",
version.ref = "activity"
}
androidx-documentfile = {
module = "androidx.documentfile:documentfile",
version.ref = "documentfile"
}
// build.gradle.kts do módulo app
dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.documentfile)
}
documentfile é opcional para abrir/criar um único arquivo via ContentResolver, mas vira o caminho mais legível ao trabalhar com pastas (OpenDocumentTree), listar filhos e criar subdocumentos.
Para o fluxo comum de anexar ou exportar, não declare READ_EXTERNAL_STORAGE / MANAGE_EXTERNAL_STORAGE. Se o app também tem galeria própria ou câmera, isole essas permissões nos fluxos que realmente precisam delas — veja o guia de permissões no Android.
Abrir um documento no Jetpack Compose
OpenDocument devolve um Uri?. null significa cancelamento:
@Composable
fun AnexarDocumentoButton(
onDocumentoSelecionado: (Uri) -> Unit,
) {
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
) { uri: Uri? ->
uri?.let(onDocumentoSelecionado)
}
Button(
onClick = {
launcher.launch(
arrayOf(
"application/pdf",
"image/*",
"text/csv",
"application/zip",
)
)
}
) {
Text("Anexar documento")
}
}
Pontos que evitam regressão:
- registre o launcher durante a composição, nunca dentro do
onClick; - filtre por MIME reais do produto —
*/*só quando fizer sentido; - valide de novo no app: o seletor ajuda, mas não é barreira de segurança;
- trate cancelamento como fluxo normal, não como erro.
Para múltiplos arquivos:
val multiLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenMultipleDocuments(),
) { uris: List<Uri> ->
if (uris.isNotEmpty()) onDocumentosSelecionados(uris)
}
Limite quantidade no domínio (ViewModel/caso de uso), não só na UI. Um usuário pode selecionar dezenas de PDFs grandes em rede móvel.
Criar / exportar um arquivo
CreateDocument pede ao usuário onde salvar e com qual nome sugerido:
@Composable
fun ExportarRelatorioButton(
sugerirNome: String = "relatorio-mensal.pdf",
onArquivoCriado: (Uri) -> Unit,
) {
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/pdf"),
) { uri ->
uri?.let(onArquivoCriado)
}
OutlinedButton(onClick = { launcher.launch(sugerirNome) }) {
Text("Exportar PDF")
}
}
Depois escreva no Uri com ContentResolver:
fun escreverPdf(
context: Context,
destino: Uri,
bytes: ByteArray,
) {
context.contentResolver.openOutputStream(destino, "wt")?.use { output ->
output.write(bytes)
output.flush()
} ?: error("Não foi possível escrever no destino escolhido")
}
Para arquivos grandes, evite ByteArray inteiro: gere em streaming (Okio, BufferedOutputStream, ou pipeline do gerador de PDF) e copie em blocos. O mesmo cuidado de memória do Photo Picker se aplica aqui.
Nunca transforme o Uri em caminho físico
O erro clássico — getRealPathFromUri() — falha com SAF com ainda mais frequência do que com mídia. O Uri pode ser:
content://com.android.providers.downloads.documents/...content://com.google.android.apps.docs.storage/...content://com.android.externalstorage.documents/tree/...
Abra sempre via ContentResolver:
fun lerBytes(context: Context, uri: Uri): ByteArray {
return context.contentResolver
.openInputStream(uri)
?.use { it.readBytes() }
?: error("Conteúdo indisponível")
}
fun copiarParaPrivado(
context: Context,
uri: Uri,
destino: File,
) {
context.contentResolver.openInputStream(uri).use { input ->
requireNotNull(input) { "Não foi possível abrir o documento" }
destino.outputStream().use { output ->
input.copyTo(output, bufferSize = 8 * 1024)
}
}
}
Para upload posterior, fila offline ou parsing demorado, copie para filesDir/cacheDir. Guardar só uri.toString() e esperar que funcione semanas depois é frágil — especialmente sem permissão persistente.
Metadados: nome, MIME e tamanho
Use OpenableColumns e getType:
data class DocumentoMeta(
val displayName: String?,
val mimeType: String?,
val sizeBytes: Long?,
)
fun consultarMeta(context: Context, uri: Uri): DocumentoMeta {
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 nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE)
return DocumentoMeta(
displayName = nameIdx.takeIf { it >= 0 }?.let(cursor::getString),
mimeType = mimeType,
sizeBytes = sizeIdx.takeIf { it >= 0 && !cursor.isNull(it) }
?.let(cursor::getLong),
)
}
}
return DocumentoMeta(displayName = null, mimeType = mimeType, sizeBytes = null)
}
Nem todo provedor informa tamanho. Quando vier nulo, conte bytes durante a cópia e aborte ao ultrapassar o limite do produto. No backend, revalide MIME real, magic bytes e tamanho — nome e Content-Type do cliente não são prova.
Persistindo acesso entre sessões
Há três estratégias, em ordem de previsibilidade:
- Consumir imediatamente — ler/enviar enquanto o fluxo está ativo.
- Copiar para armazenamento privado — melhor para upload, OCR, fila offline-first.
- Permissão persistente de URI — quando o app precisa reabrir o mesmo documento/pasta depois.
fun persistirLeitura(context: Context, uri: Uri) {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(uri, flags)
}
fun persistirLeituraEscrita(context: Context, uri: Uri) {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(uri, flags)
}
Cuidados importantes:
- só funciona se o contrato/provedor concedeu flag persistível (
OpenDocument/OpenDocumentTreenormalmente permitem; não assuma o mesmo de todo seletor); - liberte com
releasePersistableUriPermissionquando o usuário remover o anexo ou trocar a pasta; - persista o
Uri(string) e um identificador interno do negócio; não use o URI como chave primária exclusiva sem plano B; - teste reinício do aparelho e revogação pelo usuário em Ajustes.
Liste concessões ativas quando precisar auditar:
val persistidos = context.contentResolver.persistedUriPermissions
OpenDocumentTree + DocumentFile
Quando o produto pede “escolha a pasta de exportação” ou “sincronize com esta pasta”, use árvore:
@Composable
fun EscolherPastaBackup(
onPastaSelecionada: (Uri) -> Unit,
) {
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
) { treeUri ->
if (treeUri != null) {
// Persistir leitura+escrita antes de usar depois.
onPastaSelecionada(treeUri)
}
}
Button(onClick = { launcher.launch(null) }) {
Text("Escolher pasta de backup")
}
}
Com DocumentFile:
fun criarBackupJson(
context: Context,
treeUri: Uri,
nomeArquivo: String,
conteudo: String,
): Uri {
val root = DocumentFile.fromTreeUri(context, treeUri)
?: error("Árvore inválida")
val existente = root.findFile(nomeArquivo)
val alvo = existente
?: root.createFile("application/json", nomeArquivo)
?: error("Não foi possível criar $nomeArquivo")
context.contentResolver.openOutputStream(alvo.uri, "wt")?.use { output ->
output.write(conteudo.toByteArray(Charsets.UTF_8))
} ?: error("Falha ao escrever backup")
return alvo.uri
}
fun listarFilhos(
context: Context,
treeUri: Uri,
): List<DocumentFile> {
val root = DocumentFile.fromTreeUri(context, treeUri) ?: return emptyList()
return root.listFiles().toList()
}
Boas práticas com árvore:
- não percorra recursivamente pastas enormes na main thread;
- filtre por
isFile/type/ nome antes de abrir; - trate
createFileretornandonull(provedor sem suporte a escrita, pasta só leitura, quota); - evite assumir que
findFileé case-sensitive igual em todos os provedores; - para sync periódico, combine a pasta persistida com WorkManager — não mantenha um foreground service só para “vigiar a pasta”.
ViewModel: UI abre, domínio processa
O launcher fica na UI. Validação, cópia e enfileiramento ficam no domínio:
class AnexoViewModel(
private val importador: DocumentoImportador,
) : ViewModel() {
private val _state = MutableStateFlow(AnexoState())
val state = _state.asStateFlow()
fun onDocumentoEscolhido(uri: Uri) {
viewModelScope.launch {
_state.update { it.copy(importando = true, erro = null) }
runCatching { importador.importar(uri) }
.onSuccess { local ->
_state.update {
it.copy(
importando = false,
arquivoLocalId = local.id,
nomeExibicao = local.nome,
)
}
}
.onFailure { e ->
_state.update {
it.copy(
importando = false,
erro = e.message ?: "Não foi possível importar o documento",
)
}
}
}
}
}
Encapsule ContentResolver atrás de DocumentoImportador. Isso permite testar limites de MIME/tamanho sem abrir o seletor do sistema e evita Context genérico vazando para a camada de apresentação.
Upload e processamento em background
Fluxo confiável para comprovante/anexo:
- usuário escolhe o documento no SAF;
- app valida MIME/tamanho;
- copia para área privada com nome interno aleatório;
- persiste item
PENDINGno banco; - WorkManager envia com constraint de rede;
- backend revalida e devolve ID final;
- app marca
UPLOADEDe limpa temporário.
Passe só o ID no Data do worker — nunca o arquivo inteiro nem um URI sem garantia de acesso. O padrão é o mesmo do guia de WorkManager e do fluxo de mídia do Photo Picker.
SAF vs Photo Picker vs armazenamento privado
| Pergunta | Resposta curta |
|---|---|
| É foto/vídeo de anexar agora? | Photo Picker |
| É PDF/planilha/ZIP/documento genérico? | OpenDocument |
| Preciso salvar um arquivo gerado? | CreateDocument |
| Preciso de pasta duradoura? | OpenDocumentTree + permissão persistente |
| O arquivo só existe para o app? | filesDir / DataStore / Room |
| Preciso listar toda a galeria? | MediaStore + permissões |
Não misture as APIs sem necessidade. Um botão “Anexar” que às vezes abre galeria com permissão ampla e às vezes SAF confunde o usuário e complica testes. Separe “Anexar foto” e “Anexar documento” quando o produto tiver os dois.
Para dados sensíveis (comprovantes, contratos, documentos de identidade), combine SAF com as práticas de segurança de dados locais e o recorte de LGPD no Android: minimize retenção, criptografe em repouso quando o risco pedir, e não logue URI/nome completo em analytics.
Testes
Unitário
- MIME permitido/proibido;
- limite de bytes com tamanho conhecido e desconhecido;
- cópia interrompida no meio;
- limpeza após sucesso/cancelamento;
- idempotência do import (mesmo URI duas vezes não duplica negócio).
UI / instrumentado
- botão dispara o launcher;
- retorno com URI atualiza estado;
- cancelamento preserva estado anterior;
- rotação não importa duas vezes;
- processo recriado recupera item já copiado;
- árvore persistida continua acessível após cold start (teste dedicado, poucos casos).
Não dependa de strings do seletor do fabricante. Injete um callback/DocumentoImportador fake na maior parte da suíte e reserve poucos testes end-to-end reais para o SAF.
Erros comuns
- Pedir armazenamento amplo para anexar um PDF. SAF resolve sem isso.
- Converter URI em path de
File. UseContentResolver/DocumentFile. - Esquecer permissão persistente e achar que o URI “sempre funciona”.
- Persistir e nunca liberar concessões órfãs.
- Carregar PDF de 80 MB em
ByteArray. Faça streaming. - Confiar na extensão
.pdf. Consulte MIME e valide conteúdo. - Criar launcher no clique. Registre na composição.
- Usar
OpenDocumentTreepara um único arquivo. É UX e permissão em excesso. - Assumir escrita em qualquer árvore. Provedores podem ser só leitura.
- Logar URI/nome do documento. Pode conter dados pessoais.
- Fazer listagem recursiva pesada na main thread.
- Misturar Photo Picker e SAF no mesmo botão sem deixar a escolha clara.
Checklist de produção
activity-compose(edocumentfilese usar árvore) no Version Catalog.OpenDocument/CreateDocument/OpenDocumentTreeconforme o caso.- Sem permissão ampla de armazenamento no fluxo de anexo/export.
- MIME filters alinhados ao produto + revalidação local.
Uritratado comContentResolver, sem “real path”.- Cópia privada antes de upload/OCR adiado.
takePersistableUriPermissionsó quando o acesso precisa sobreviver;releaseno ciclo de vida do vínculo.- WorkManager com ID persistido e constraint de rede.
- Backend revalida tipo/tamanho/conteúdo.
- Temporários com política de limpeza.
- Cancelamento, cold start e provedor de nuvem testados.
- Acessibilidade: botões com rótulo claro (“Anexar PDF”, “Escolher pasta”).
Perguntas frequentes
Storage Access Framework precisa de permissão de armazenamento?
Para seleção explícita de arquivo/pasta, não. O sistema concede acesso aos itens (ou à árvore) escolhidos pelo usuário. Permissões amplas entram em outros desenhos de produto, não neste fluxo.
Qual a diferença entre OpenDocument e Photo Picker?
Photo Picker é otimizado para imagens/vídeos com UX de galeria moderna e privacidade por item. OpenDocument cobre documentos genéricos (PDF, CSV, ZIP, etc.) e tipos MIME arbitrários. Use cada um no seu botão.
Como manter acesso à pasta depois de fechar o app?
Com OpenDocumentTree, chame takePersistableUriPermission com flags de leitura/escrita, salve o Uri da árvore e reabra com DocumentFile.fromTreeUri. Ofereça na UI uma ação para trocar ou revogar a pasta.
DocumentFile ainda vale a pena em 2026?
Sim, como camada conveniente sobre URIs de documento/árvore. Para um único arquivo aberto/criado, ContentResolver puro basta. Para criar filhos, listar e navegar pasta, DocumentFile reduz boilerplate — sem eliminar a necessidade de validar retornos nulos e falhas do provedor.
Posso usar SAF para backup automático noturno?
Sim: o usuário escolhe a pasta uma vez, você persiste a permissão e agenda o trabalho no WorkManager. Não mantenha serviço contínuo só para isso. Se o horário precisar ser exato (alarme), aí entra AlarmManager — outro tópico; backup de arquivo raramente precisa de alarme exato.
O que fazer se openInputStream retornar null?
Mostre erro recuperável, ofereça escolher de novo e registre telemetria agregada (tipo de falha), sem URI completo. Causas comuns: permissão revogada, item apagado, provedor offline, árvore inválida.
Conclusão
O Storage Access Framework com Kotlin é o padrão certo para documentos e pastas escolhidos pelo usuário no Android de 2026: menos permissão, melhor compatibilidade com nuvem e Downloads, e um contrato claro via Uri. A implementação enxuta — OpenDocument/CreateDocument/OpenDocumentTree + ContentResolver/DocumentFile — só se torna robusta quando você valida MIME/tamanho, evita caminhos físicos, decide entre cópia privada e permissão persistente, e processa em background com WorkManager.
A decisão de produto mais importante é separar seleção pontual de acesso amplo. Se o usuário aponta o arquivo, SAF (ou Photo Picker para mídia) resolve. Se o app insiste em varrer o aparelho, o custo aparece em privacidade, Play policy e suporte.
Para continuar, leia Photo Picker com Compose, permissões no Android, segurança de dados locais, WorkManager e offline-first. Para aplicar a stack em produtos reais, acompanhe as vagas Kotlin e Android no Kotlin Brasil.