Zygote
Storageintermediate

The Storage Access Framework

A picker, a provider interface and a persistent URI grant: how an app gets at a file it does not own without ever learning a path.

11 minAPI 19–37 · 17interactivequizverified 2026-10-27

The Storage Access Framework is the oldest part of Android's modern storage model and still the only way to access documents the app has no business enumerating. It is built around one idea: the user chooses, and the app receives a capability rather than a path.

TL;DR#

  • SAF launched in API 19Android 4.4 with ACTION_OPEN_DOCUMENT and ACTION_CREATE_DOCUMENT.
  • A DocumentsProvider exposes a tree of documents; the system picker draws it.
  • The app receives a content URI and a temporary read/write grant on exactly that URI.
  • takePersistableUriPermission makes the grant survive a reboot.
  • ACTION_OPEN_DOCUMENT_TREE grants access to a whole directory — the modern replacement for a broad storage permission for file managers.

Mental model#

Think of a bank vault.

You do not get a key to the vault. You fill in a request slip, a clerk fetches the box, and you get the box for a specific purpose. The clerk records the access.

The clerk is the DocumentsProvider. The slip is the intent. The box is the content URI, and the record is the URI grant.

Where the analogy breaks#

A vault has one clerk. SAF has many providers — local storage, Google Drive, a cloud photo service — and the picker shows all of them in one UI. The app does not know which provider answered, and does not need to. That polymorphism is the point: the same code opens a local file and a cloud document.

The big picture#

Loading diagram…
Text description of this diagram

A sequence from the app launching ACTION_OPEN_DOCUMENT through the system picker, which queries a DocumentsProvider through the framework, receives a document URI, and passes it back with a URI grant. The app can then take a persistable permission so the grant survives a reboot, and read the file through ContentResolver without ever seeing a filesystem path.

Step through the sequence and notice where the app is not involved: the provider listing, the tree navigation and the permission grant all happen without the app's code running.

Deep dive#

The two intents#

// Open an existing document
val open = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
    addCategory(Intent.CATEGORY_OPENABLE)
    type = "application/pdf"
}
 
// Create a new document
val create = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
    addCategory(Intent.CATEGORY_OPENABLE)
    type = "text/plain"
    putExtra(Intent.EXTRA_TITLE, "notes.txt")
}

Both return a URI in the activity result. ACTION_OPEN_DOCUMENT_TREE returns a tree URI that represents a directory.

The type is a MIME type, not a file extension, and the picker filters by it. */* shows everything.

What the app receives#

A content URI such as:

content://com.android.providers.downloads.documents/document/msf%3A1000000042

The URI identifies a document inside a provider. It is opaque: the app must not parse it, construct one, or assume it corresponds to a file path. That opacity is what lets the same API address a local file and a cloud document.

Reading and writing#

contentResolver.openInputStream(uri)?.use { stream ->
    // read
}
 
contentResolver.openOutputStream(uri)?.use { stream ->
    // write
}

or, for a file descriptor:

contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
    // use pfd.fileDescriptor
}

The provider decides what these mean. A local provider returns a file descriptor; a cloud provider may stream the content into a temporary file first.

Persistable grants#

A grant obtained from the picker lasts for the life of the task by default. To keep it:

contentResolver.takePersistableUriPermission(
    uri,
    Intent.FLAG_GRANT_READ_URI_PERMISSION
)

The grant is then recorded by the system and survives reboots. There is a limit on the number of persisted grants per app, and the user can revoke them.

The important subtlety: a persisted grant is per-URI, not per-provider. Taking a grant on one document does not grant access to its siblings. For a directory, ACTION_OPEN_DOCUMENT_TREE and a tree grant do cover children.

DocumentsProvider#

A provider implements a small set of methods:

MethodPurpose
queryRootswhat the provider exposes (a local drive, a cloud account)
queryDocumentmetadata for one document
queryChildDocumentsthe children of a directory
openDocumenta file descriptor for reading or writing
createDocumentcreate a new file
deleteDocument, renameDocumentmodify
isChildDocumentaccess checks

The provider is a normal ContentProvider with an authority, declared in the manifest with the android.content.action.DOCUMENTS_PROVIDER intent filter.1 The system's picker discovers it automatically.

Tree grants#

ACTION_OPEN_DOCUMENT_TREE lets the user choose a directory. The app receives a tree URI and a grant that covers the directory and its descendants.

This is the supported way to build a file manager or a backup tool without MANAGE_EXTERNAL_STORAGE. It is also the mechanism behind "choose a folder for your notes" features.

The app can create documents inside the tree, enumerate children, and delete — all within the granted directory.

The picker versus the photo picker#

The photo picker is a specialised, more restrictive UI for images and video. The Storage Access Framework picker is general: any MIME type, any provider, including directories.

Use the photo picker for photos. Use SAF for documents, for directories, and for anything a provider other than the local filesystem might supply.

What SAF does not give you#

SAF does not give random access to a file's bytes at arbitrary offsets in every case; the provider may only support sequential reads. It does not guarantee that the URI remains valid if the provider deletes the file. And it does not tell you when the grant is revoked — you find out when the operation fails.

Why the URI is opaque#

A SAF URI names a document inside a provider, not a file on a disk. The provider may map it to a local file, a cloud object, or a row in a database.

Opacity is a security property as much as an abstraction one. If an app could parse the URI and construct a sibling URI, it could bypass the per-item grant the user gave. The provider validates each request against the grant, so a constructed URI fails.

The provider lifecycle#

A DocumentsProvider is a ContentProvider, so it is created on first access and lives in whichever process declares it. For a cloud provider, that means the provider's process may need to authenticate, which is why the first queryRoots can be slow.

The picker handles this by showing a loading state per root rather than blocking the whole UI.

Persisted grants and their limits#

A persisted grant is recorded by the system's URI grant manager. The app can enumerate its persisted grants with contentResolver.persistedUriPermissions, which is the reliable way to check whether a tree grant is still valid after an upgrade.

Grants can be revoked by the user, by the provider, or by the system when the app is uninstalled. An app must handle a SecurityException on access rather than assuming the grant persists forever.

SAF versus a content provider you own#

If the app owns the data, it does not need SAF. A ContentProvider with its own authority, exposed through a ContentProvider and protected by permissions, is the simpler model.

SAF is for data the app does not own: files on the user's storage, documents in another provider, folders the user chooses.

The intent flags that matter#

The picker result carries grant flags. An app that reads the result but does not pass FLAG_GRANT_READ_URI_PERMISSION to a downstream component — another activity, a service — loses access when the first activity finishes.

Passing the flag explicitly on each Intent is the fix, and forgetting it is the most common SAF bug after the missing persistable grant.

Why a picker instead of a permission dialog#

A permission dialog asks the user to trust an app with a capability. A picker asks them to choose an item.

The picker is better on both sides: the user makes a concrete decision with the content in front of them, and the app gets exactly what it needs. The permission becomes unnecessary rather than merely narrower.

That is the pattern the platform has followed across storage, photos and, increasingly, contacts and location.

What SAF is not for#

SAF is not a replacement for an app's own storage. An app that stores its data in a SAF tree chosen by the user has made its data dependent on a grant that can be revoked.

It is also not a general file browser: it shows the providers the system knows about, in the UI the system provides. An app that needs a custom file browser implements its own UI over MediaStore and the filesystem.

The grant lifecycle, end to end#

  1. The picker returns a URI with a temporary grant.
  2. The app optionally persists the grant.
  3. The app reads through ContentResolver.
  4. The user may revoke the grant in Settings.
  5. The app's next access throws SecurityException.

Step 5 is the one apps forget. A storage feature must handle a revoked grant the same way it handles a cancelled picker: ask again.

Interoperability with other apps#

A SAF URI can be passed to another app with a grant flag, which is how an app hands a document to a viewer or an editor without either app having storage permission.

The flag is per-intent and per-URI. Passing it is the app's responsibility, and forgetting it produces a SecurityException in the receiving app with no obvious cause.

How it evolved#

API 19 →SAF launchopen and create documentAPI 19
API 21 →Tree accessdirectory grantsAPI 21
API 29 →Scoped storageSAF becomes the document pathAPI 29
API 33 →Photo pickera specialised picker for mediaAPI 33
Storage changes10 releases
APIVersionWhat changed
31.5 Cupcake
  • android.widget video and media improvements
123.1 Honeycomb
  • ParcelFileDescriptor improvements
154.0.3 Ice Cream Sandwich
  • New calendar and media provider APIs
194.4 KitKat
  • Storage Access Framework
247.0 Nougat
  • FileProvider and StrictMode for file URLs
2910
  • Scoped storage with requestLegacyExternalStorage opt-out
3011
  • Scoped storage enforced for targeting apps
3313 Tiramisu
  • READ_MEDIA_IMAGES/VIDEO/AUDIO replace storage reads
3414 Upside Down Cake
  • READ_MEDIA_VISUAL_USER_SELECTED
3717 Cinnamon Bun
  • ACTION_PICK_CONTACTS gives session-scoped contact field access

Under the hood#

Curiosity corner#

  • The picker UI is an app. DocumentsUI is a system app, not part of system_server, which is why it can be updated separately.
  • A provider can be remote. A cloud provider may block on a network request during queryChildDocuments, which is why the picker shows a spinner.
  • Persisted grants are capped. An app cannot accumulate unlimited grants; the system limits them and drops the oldest.

Try it#

List document providers
adb shell dumpsys activity providers | grep -i "documents"
Example output
com.android.providers.downloads.documents
com.android.externalstorage.documents
com.google.android.apps.docs.storage
Query a provider root
adb shell content query --uri content://com.android.externalstorage.documents/root
Example output
Row: 0 root_id=primary, title=Internal storage
Persisted URI grants
adb shell dumpsys activity uri-permissions
Example output
Persisted URI permissions:
content://com.android.externalstorage.documents/tree/primary%3ANotes

Check yourself#

Check yourself

1 / 12

Why did WRITE_EXTERNAL_STORAGE become a privacy problem?

0 answered · 0 correct

Sources#

Sources

Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.

  1. docsOpen files using the Storage Access Framework | App data and filesdeveloper.android.comaccessed 2026-10-27↩
  2. docsData and file storage overview | App data and filesdeveloper.android.comaccessed 2026-10-27↩
  3. docsStorage updates in Android 11developer.android.comaccessed 2026-10-27↩
  4. docsStorage overviewsource.android.comaccessed 2026-10-27↩
  5. docsPerubahan perilaku: aplikasi yang menargetkan API level 28+developer.android.comaccessed 2026-10-27↩