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?

NecessidadeAPI recomendadaPermissão ampla?
Anexar PDF, XML, CSV, ZIP, DOCXOpenDocumentNão
Exportar/salvar um arquivo gerado pelo appCreateDocumentNão
Backup/export contínuo em pasta escolhidaOpenDocumentTree + DocumentFileNão
Escolher foto/vídeo pontualPhoto PickerNão
Galeria própria listando a bibliotecaMediaStorePode exigir
Arquivos privados só do appfilesDir / cacheDirNão
Download grande gerenciado pelo sistemaDownloadManagerDepende 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:

  1. registre o launcher durante a composição, nunca dentro do onClick;
  2. filtre por MIME reais do produto — */* só quando fizer sentido;
  3. valide de novo no app: o seletor ajuda, mas não é barreira de segurança;
  4. 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:

  1. Consumir imediatamente — ler/enviar enquanto o fluxo está ativo.
  2. Copiar para armazenamento privado — melhor para upload, OCR, fila offline-first.
  3. 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 / OpenDocumentTree normalmente permitem; não assuma o mesmo de todo seletor);
  • liberte com releasePersistableUriPermission quando 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 createFile retornando null (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:

  1. usuário escolhe o documento no SAF;
  2. app valida MIME/tamanho;
  3. copia para área privada com nome interno aleatório;
  4. persiste item PENDING no banco;
  5. WorkManager envia com constraint de rede;
  6. backend revalida e devolve ID final;
  7. app marca UPLOADED e 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

PerguntaResposta 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

  1. Pedir armazenamento amplo para anexar um PDF. SAF resolve sem isso.
  2. Converter URI em path de File. Use ContentResolver / DocumentFile.
  3. Esquecer permissão persistente e achar que o URI “sempre funciona”.
  4. Persistir e nunca liberar concessões órfãs.
  5. Carregar PDF de 80 MB em ByteArray. Faça streaming.
  6. Confiar na extensão .pdf. Consulte MIME e valide conteúdo.
  7. Criar launcher no clique. Registre na composição.
  8. Usar OpenDocumentTree para um único arquivo. É UX e permissão em excesso.
  9. Assumir escrita em qualquer árvore. Provedores podem ser só leitura.
  10. Logar URI/nome do documento. Pode conter dados pessoais.
  11. Fazer listagem recursiva pesada na main thread.
  12. Misturar Photo Picker e SAF no mesmo botão sem deixar a escolha clara.

Checklist de produção

  • activity-compose (e documentfile se usar árvore) no Version Catalog.
  • OpenDocument / CreateDocument / OpenDocumentTree conforme o caso.
  • Sem permissão ampla de armazenamento no fluxo de anexo/export.
  • MIME filters alinhados ao produto + revalidação local.
  • Uri tratado com ContentResolver, sem “real path”.
  • Cópia privada antes de upload/OCR adiado.
  • takePersistableUriPermission só quando o acesso precisa sobreviver; release no 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.