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.
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_DOCUMENTandACTION_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.
takePersistableUriPermissionmakes the grant survive a reboot.ACTION_OPEN_DOCUMENT_TREEgrants 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#
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%3A1000000042The 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:
| Method | Purpose |
|---|---|
queryRoots | what the provider exposes (a local drive, a cloud account) |
queryDocument | metadata for one document |
queryChildDocuments | the children of a directory |
openDocument | a file descriptor for reading or writing |
createDocument | create a new file |
deleteDocument, renameDocument | modify |
isChildDocument | access 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#
- The picker returns a URI with a temporary grant.
- The app optionally persists the grant.
- The app reads through
ContentResolver. - The user may revoke the grant in Settings.
- 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 | Version | What changed |
|---|---|---|
| 3 | 1.5 Cupcake |
|
| 12 | 3.1 Honeycomb |
|
| 15 | 4.0.3 Ice Cream Sandwich |
|
| 19 | 4.4 KitKat |
|
| 24 | 7.0 Nougat |
|
| 29 | 10 |
|
| 30 | 11 |
|
| 33 | 13 Tiramisu |
|
| 34 | 14 Upside Down Cake |
|
| 37 | 17 Cinnamon Bun |
|
Under the hood#
- DocumentsProvider.javathe provider base class and its contract
- DocumentsContract.javathe URI shapes and the document/tree helpers
- the system picker
- UriGrantsManagerService.javawhere grants are recorded and enforced
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#
adb shell dumpsys activity providers | grep -i "documents"Example output
com.android.providers.downloads.documents
com.android.externalstorage.documents
com.google.android.apps.docs.storageadb shell content query --uri content://com.android.externalstorage.documents/rootExample output
Row: 0 root_id=primary, title=Internal storageadb shell dumpsys activity uri-permissionsExample output
Persisted URI permissions:
content://com.android.externalstorage.documents/tree/primary%3ANotesCheck yourself#
Check yourself
Why did WRITE_EXTERNAL_STORAGE become a privacy problem?
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.
- docsOpen files using the Storage Access Framework | App data and filesdeveloper.android.comaccessed 2026-10-27↩
- docsData and file storage overview | App data and filesdeveloper.android.comaccessed 2026-10-27↩
- docsStorage updates in Android 11developer.android.comaccessed 2026-10-27↩
- docsStorage overviewsource.android.comaccessed 2026-10-27↩
- docsPerubahan perilaku: aplikasi yang menargetkan API level 28+developer.android.comaccessed 2026-10-27↩