Android 4.4 (API level 19) introduces the Storage Access Framework (SAF). The SAF makes it simple for users to browse and open documents, images, and other files across all of their preferred document storage providers. A standard, easy-to-use UI lets users browse files and access recents in a consistent way across apps and providers.
Cloud or local storage services can participate in this ecosystem by implementing a
DocumentsProvider
that encapsulates their services. Client
apps that need access to a provider's documents can integrate with the SAF with just a few
lines of code.
The SAF includes the following:
- Document provider—A content provider that allows a
storage service (such as Google Drive) to reveal the files it manages. A document provider is
implemented as a subclass of the
DocumentsProvider
class. The document-provider schema is based on a traditional file hierarchy, though how your document provider physically stores data is up to you. The Android platform includes several built-in document providers, such as Downloads, Images, and Videos. - Client app—A custom app that invokes the
ACTION_OPEN_DOCUMENT
and/orACTION_CREATE_DOCUMENT
intent and receives the files returned by document providers. - Picker—A system UI that lets users access documents from all document providers that satisfy the client app's search criteria.
Some of the features offered by the SAF are as follows:
- Lets users browse content from all document providers, not just a single app.
- Makes it possible for your app to have long term, persistent access to documents owned by a document provider. Through this access users can add, edit, save, and delete files on the provider.
- Supports multiple user accounts and transient roots such as USB storage providers, which only appear if the drive is plugged in.
Overview
The SAF centers around a content provider that is a
subclass of the DocumentsProvider
class. Within a document provider, data is
structured as a traditional file hierarchy:
Figure 1. Document provider data model. A Root points to a single Document, which then starts the fan-out of the entire tree.
Note the following:
- Each document provider reports one or more
'roots', which are starting points into exploring a tree of documents.
Each root has a unique
COLUMN_ROOT_ID
, and it points to a document (a directory) representing the contents under that root. Roots are dynamic by design to support use cases like multiple accounts, transient USB storage devices, or user login/logout. - Under each root is a single document. That document points to 1 to N documents, each of which in turn can point to 1 to N documents.
- Each storage backend surfaces
individual files and directories by referencing them with a unique
COLUMN_DOCUMENT_ID
. Document IDs must be unique and not change once issued, since they are used for persistent URI grants across device reboots. - Documents can be either an openable file (with a specific MIME type), or a
directory containing additional documents (with the
MIME_TYPE_DIR
MIME type). - Each document can have different capabilities, as described by
COLUMN_FLAGS
. For example,FLAG_SUPPORTS_WRITE
,FLAG_SUPPORTS_DELETE
, andFLAG_SUPPORTS_THUMBNAIL
. The sameCOLUMN_DOCUMENT_ID
can be included in multiple directories.
Control flow
As stated above, the document provider data model is based on a traditional
file hierarchy. However, you can physically store your data however you like, as
long as you can access it by using DocumentsProvider
API. For example, you could use tag-based cloud storage for your data.
Figure 2 shows how a photo app might use the SAF to access stored data:
Figure 2. Storage Access Framework Flow
Note the following:
- In the SAF, providers and clients don't interact directly. A client requests permission to interact with files (that is, to read, edit, create, or delete files).
- The interaction starts when an application (in this example, a photo app) fires the intent
ACTION_OPEN_DOCUMENT
orACTION_CREATE_DOCUMENT
. The intent can include filters to further refine the criteria—for example, "give me all openable files that have the 'image' MIME type." - Once the intent fires, the system picker goes to each registered provider and shows the user the matching content roots.
- The picker gives users a standard interface for accessing documents, even though the underlying document providers may be very different. For example, figure 2 shows a Google Drive provider, a USB provider, and a cloud provider.
Figure 3 shows a picker in which a user searching for images has selected a Google Drive account. It also shows all of the roots available to the client app.
Figure 3. Picker
When the user selects Google Drive the images are displayed as shown in figure 4. From that point on, the user can interact with them in the ways supported by the provider and client app.
Figure 4. Images
Writing a client app
On Android 4.3 and lower, if you want your app to retrieve a file from another
app, it must invoke an intent such as ACTION_PICK
or ACTION_GET_CONTENT
. The user must then select
a single app from which to pick a file and the selected app must provide a user
interface for the user to browse and pick from the available files.
On Android 4.4 and higher, you have the additional option of using the
ACTION_OPEN_DOCUMENT
intent,
which displays a system-controlled picker UI controlled that allows the user to
browse all files that other apps have made available. From this single UI, the
user can pick a file from any of the supported apps.
ACTION_OPEN_DOCUMENT
is
not intended to be a replacement for ACTION_GET_CONTENT
.
The one you should use depends on the needs of your app:
- Use
ACTION_GET_CONTENT
if you want your app to simply read/import data. With this approach, the app imports a copy of the data, such as an image file. - Use
ACTION_OPEN_DOCUMENT
if you want your app to have long term, persistent access to documents owned by a document provider. An example would be a photo-editing app that lets users edit images stored in a document provider.
This section describes how to write client apps based on the
ACTION_OPEN_DOCUMENT
and
ACTION_CREATE_DOCUMENT
intents.
Search for documents
The following snippet uses ACTION_OPEN_DOCUMENT
to search for document providers that
contain image files:
Kotlin
private const val READ_REQUEST_CODE: Int = 42 ... /** * Fires an intent to spin up the "file chooser" UI and select an image. */ fun performFileSearch() { // ACTION_OPEN_DOCUMENT is the intent to choose a file via the system's file // browser. val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { // Filter to only show results that can be "opened", such as a // file (as opposed to a list of contacts or timezones) addCategory(Intent.CATEGORY_OPENABLE) // Filter to show only images, using the image MIME data type. // If one wanted to search for ogg vorbis files, the type would be "audio/ogg". // To search for all documents available via installed storage providers, // it would be "*/*". type = "image/*" } startActivityForResult(intent, READ_REQUEST_CODE) }
Java
private static final int READ_REQUEST_CODE = 42; ... /** * Fires an intent to spin up the "file chooser" UI and select an image. */ public void performFileSearch() { // ACTION_OPEN_DOCUMENT is the intent to choose a file via the system's file // browser. Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); // Filter to only show results that can be "opened", such as a // file (as opposed to a list of contacts or timezones) intent.addCategory(Intent.CATEGORY_OPENABLE); // Filter to show only images, using the image MIME data type. // If one wanted to search for ogg vorbis files, the type would be "audio/ogg". // To search for all documents available via installed storage providers, // it would be "*/*". intent.setType("image/*"); startActivityForResult(intent, READ_REQUEST_CODE); }
Note the following:
- When the app fires the
ACTION_OPEN_DOCUMENT
intent, it launches a picker that displays all matching document providers. - Adding the category
CATEGORY_OPENABLE
to the intent filters the results to display only documents that can be opened, such as image files. - The statement
intent.setType("image/*")
further filters to display only documents that have the image MIME data type.
Process results
After the user selects a document in the picker,
onActivityResult()
gets called.
The resultData
parameter contains the URI that points to the
selected document. Extract the URI using getData()
.
When you have it, you can use it to retrieve the document the user wants. For
example:
Kotlin
override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) { // The ACTION_OPEN_DOCUMENT intent was sent with the request code // READ_REQUEST_CODE. If the request code seen here doesn't match, it's the // response to some other intent, and the code below shouldn't run at all. if (requestCode == READ_REQUEST_CODE && resultCode == Activity.RESULT_OK) { // The document selected by the user won't be returned in the intent. // Instead, a URI to that document will be contained in the return intent // provided to this method as a parameter. // Pull that URI using resultData.getData(). resultData?.data?.also { uri -> Log.i(TAG, "Uri: $uri") showImage(uri) } } }
Java
@Override public void onActivityResult(int requestCode, int resultCode, Intent resultData) { // The ACTION_OPEN_DOCUMENT intent was sent with the request code // READ_REQUEST_CODE. If the request code seen here doesn't match, it's the // response to some other intent, and the code below shouldn't run at all. if (requestCode == READ_REQUEST_CODE && resultCode == Activity.RESULT_OK) { // The document selected by the user won't be returned in the intent. // Instead, a URI to that document will be contained in the return intent // provided to this method as a parameter. // Pull that URI using resultData.getData(). Uri uri = null; if (resultData != null) { uri = resultData.getData(); Log.i(TAG, "Uri: " + uri.toString()); showImage(uri); } } }
Examine document metadata
When you have the URI for a document, you gain access to its metadata. This snippet grabs the metadata for a document specified by the URI, and logs it:
Kotlin
fun dumpImageMetaData(uri: Uri) { // The query, since it only applies to a single document, will only return // one row. There's no need to filter, sort, or select fields, since we want // all fields for one document. val cursor: Cursor? = contentResolver.query( uri, null, null, null, null, null) cursor?.use { // moveToFirst() returns false if the cursor has 0 rows. Very handy for // "if there's anything to look at, look at it" conditionals. if (it.moveToFirst()) { // Note it's called "Display Name". This is // provider-specific, and might not necessarily be the file name. val displayName: String = it.getString(it.getColumnIndex(OpenableColumns.DISPLAY_NAME)) Log.i(TAG, "Display Name: $displayName") val sizeIndex: Int = it.getColumnIndex(OpenableColumns.SIZE) // If the size is unknown, the value stored is null. But since an // int can't be null in Java, the behavior is implementation-specific, // which is just a fancy term for "unpredictable". So as // a rule, check if it's null before assigning to an int. This will // happen often: The storage API allows for remote files, whose // size might not be locally known. val size: String = if (!it.isNull(sizeIndex)) { // Technically the column stores an int, but cursor.getString() // will do the conversion automatically. it.getString(sizeIndex) } else { "Unknown" } Log.i(TAG, "Size: $size") } } }
Java
public void dumpImageMetaData(Uri uri) { // The query, since it only applies to a single document, will only return // one row. There's no need to filter, sort, or select fields, since we want // all fields for one document. Cursor cursor = getActivity().getContentResolver() .query(uri, null, null, null, null, null); try { // moveToFirst() returns false if the cursor has 0 rows. Very handy for // "if there's anything to look at, look at it" conditionals. if (cursor != null && cursor.moveToFirst()) { // Note it's called "Display Name". This is // provider-specific, and might not necessarily be the file name. String displayName = cursor.getString( cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)); Log.i(TAG, "Display Name: " + displayName); int sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE); // If the size is unknown, the value stored is null. But since an // int can't be null in Java, the behavior is implementation-specific, // which is just a fancy term for "unpredictable". So as // a rule, check if it's null before assigning to an int. This will // happen often: The storage API allows for remote files, whose // size might not be locally known. String size = null; if (!cursor.isNull(sizeIndex)) { // Technically the column stores an int, but cursor.getString() // will do the conversion automatically. size = cursor.getString(sizeIndex); } else { size = "Unknown"; } Log.i(TAG, "Size: " + size); } } finally { cursor.close(); } }
Open a document
Once you have the URI for a document, you can open it or do whatever else you want to do with it.
Bitmap
Here is an example of how you might open a Bitmap
:
Kotlin
@Throws(IOException::class) private fun getBitmapFromUri(uri: Uri): Bitmap { val parcelFileDescriptor: ParcelFileDescriptor = contentResolver.openFileDescriptor(uri, "r") val fileDescriptor: FileDescriptor = parcelFileDescriptor.fileDescriptor val image: Bitmap = BitmapFactory.decodeFileDescriptor(fileDescriptor) parcelFileDescriptor.close() return image }
Java
private Bitmap getBitmapFromUri(Uri uri) throws IOException { ParcelFileDescriptor parcelFileDescriptor = getContentResolver().openFileDescriptor(uri, "r"); FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor(); Bitmap image = BitmapFactory.decodeFileDescriptor(fileDescriptor); parcelFileDescriptor.close(); return image; }
Note that you should not do this operation on the UI thread. Do it in the
background, using AsyncTask
. Once you open the bitmap, you
can display it in an ImageView
.
Get an InputStream
Here is an example of how you can get an InputStream
from the URI. In this
snippet, the lines of the file are being read into a string:
Kotlin
@Throws(IOException::class) private fun readTextFromUri(uri: Uri): String { val stringBuilder = StringBuilder() contentResolver.openInputStream(uri)?.use { inputStream -> BufferedReader(InputStreamReader(inputStream)).use { reader -> var line: String? = reader.readLine() while (line != null) { stringBuilder.append(line) line = reader.readLine() } } } return stringBuilder.toString() }
Java
private String readTextFromUri(Uri uri) throws IOException { InputStream inputStream = getContentResolver().openInputStream(uri); BufferedReader reader = new BufferedReader(new InputStreamReader( inputStream)); StringBuilder stringBuilder = new StringBuilder(); String line; while ((line = reader.readLine()) != null) { stringBuilder.append(line); } fileInputStream.close(); parcelFileDescriptor.close(); return stringBuilder.toString(); }
Create a document
Your app can use the ACTION_CREATE_DOCUMENT
intent to create a new document in a document provider.
To create a file, give your intent a MIME type and a file name and then
launch it with a unique request code. The rest is taken care of for you:
Kotlin
// Here are some examples of how you might call this method. // The first parameter is the MIME type, and the second parameter is the name // of the file you are creating: // // createFile("text/plain", "foobar.txt"); // createFile("image/png", "mypicture.png"); // Unique request code. private const val WRITE_REQUEST_CODE: Int = 43 ... private fun createFile(mimeType: String, fileName: String) { val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { // Filter to only show results that can be "opened", such as // a file (as opposed to a list of contacts or timezones). addCategory(Intent.CATEGORY_OPENABLE) // Create a file with the requested MIME type. type = mimeType putExtra(Intent.EXTRA_TITLE, fileName) } startActivityForResult(intent, WRITE_REQUEST_CODE) }
Java
// Here are some examples of how you might call this method. // The first parameter is the MIME type, and the second parameter is the name // of the file you are creating: // // createFile("text/plain", "foobar.txt"); // createFile("image/png", "mypicture.png"); // Unique request code. private static final int WRITE_REQUEST_CODE = 43; ... private void createFile(String mimeType, String fileName) { Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); // Filter to only show results that can be "opened", such as // a file (as opposed to a list of contacts or timezones). intent.addCategory(Intent.CATEGORY_OPENABLE); // Create a file with the requested MIME type. intent.setType(mimeType); intent.putExtra(Intent.EXTRA_TITLE, fileName); startActivityForResult(intent, WRITE_REQUEST_CODE); }
After you create a new document you can get its URI in
onActivityResult()
so that you
can continue to write to it.
Delete a document
If you have the URI for a document and the document's
Document.COLUMN_FLAGS
contains
SUPPORTS_DELETE
,
you can delete the document. For example:
Kotlin
DocumentsContract.deleteDocument(contentResolver, uri)
Java
DocumentsContract.deleteDocument(getContentResolver(), uri);
Edit a document
You can use the SAF to edit a text document in place.
This snippet fires
the ACTION_OPEN_DOCUMENT
intent and uses the
category CATEGORY_OPENABLE
to display only
openable documents. It further filters to show only text files:
Kotlin
private const val EDIT_REQUEST_CODE: Int = 44 /** * Open a file for writing and append some text to it. */ private fun editDocument() { // ACTION_OPEN_DOCUMENT is the intent to choose a file via the system's // file browser. val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { // Filter to only show results that can be "opened", such as a // file (as opposed to a list of contacts or timezones). addCategory(Intent.CATEGORY_OPENABLE) // Filter to show only text files. type = "text/plain" } startActivityForResult(intent, EDIT_REQUEST_CODE) }
Java
private static final int EDIT_REQUEST_CODE = 44; /** * Open a file for writing and append some text to it. */ private void editDocument() { // ACTION_OPEN_DOCUMENT is the intent to choose a file via the system's // file browser. Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); // Filter to only show results that can be "opened", such as a // file (as opposed to a list of contacts or timezones). intent.addCategory(Intent.CATEGORY_OPENABLE); // Filter to show only text files. intent.setType("text/plain"); startActivityForResult(intent, EDIT_REQUEST_CODE); }
Next, from onActivityResult()
(see Process results) you can call code to perform the edit.
The following snippet gets a FileOutputStream
from the ContentResolver
. By default it uses write mode.
It's best practice to ask for the least amount of access you need, so don’t ask
for read/write if all you need is write:
Kotlin
private fun alterDocument(uri: Uri) { try { contentResolver.openFileDescriptor(uri, "w")?.use { // use{} lets the document provider know you're done by automatically closing the stream FileOutputStream(it.fileDescriptor).use { it.write( ("Overwritten by MyCloud at ${System.currentTimeMillis()}\n").toByteArray() ) } } } catch (e: FileNotFoundException) { e.printStackTrace() } catch (e: IOException) { e.printStackTrace() } }
Java
private void alterDocument(Uri uri) { try { ParcelFileDescriptor pfd = getActivity().getContentResolver(). openFileDescriptor(uri, "w"); FileOutputStream fileOutputStream = new FileOutputStream(pfd.getFileDescriptor()); fileOutputStream.write(("Overwritten by MyCloud at " + System.currentTimeMillis() + "\n").getBytes()); // Let the document provider know you're done by closing the stream. fileOutputStream.close(); pfd.close(); } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } }
Persist permissions
When your app opens a file for reading or writing, the system gives your app a URI permission grant for that file, which lasts until the user's device restarts. But suppose your app is an image-editing app, and you want users to be able to access the last 5 images they edited, directly from your app. If the user's device has restarted, you'd have to send the user back to the system picker to find the files, which is obviously not ideal.
To prevent this from happening, you can persist the permissions that the system gives your app. Effectively, your app "takes" the persistable URI permission grant that the system is offering. This gives the user continued access to the files through your app, even if the device has been restarted:
Kotlin
val takeFlags: Int = intent.flags and (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) // Check for the freshest data. contentResolver.takePersistableUriPermission(uri, takeFlags)
Java
final int takeFlags = intent.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); // Check for the freshest data. getContentResolver().takePersistableUriPermission(uri, takeFlags);
There is one final step. The most recent URIs that your app accessed may no
longer be valid—another app may have deleted or modified a document.
Thus, you should always call
getContentResolver().takePersistableUriPermission()
to check for the
freshest data.
Open virtual files
Android 7.0 adds the concept of virtual files to the Storage Access
Framework. Even though virtual files do not have a binary representation,
your client app can open their contents by coercing them into a different
file type or by viewing those files by using an
ACTION_VIEW
intent.
To open virtual files, your client app needs to include special logic to handle them. If you want to get a byte representation of the file—to preview the file, for example—you need to request for an alternate MIME type from the documents provider.
To get a URI for a virtual document in your app, first you create an
Intent
to open the file picker UI, like
the code shown previously in Seach for documents.
After the user makes a selection, the system calls the
onActivityResult()
method,
as shown previously in Process results.
Your app can retrieve the URI of the file and then determine whether
the file is virtual using a method similar to the following code snippet.
Kotlin
private fun isVirtualFile(uri: Uri): Boolean { if (!DocumentsContract.isDocumentUri(this, uri)) { return false } val cursor: Cursor? = contentResolver.query( uri, arrayOf(DocumentsContract.Document.COLUMN_FLAGS), null, null, null ) val flags: Int = cursor?.use { if (cursor.moveToFirst()) { cursor.getInt(0) } else { 0 } } ?: 0 return flags and DocumentsContract.Document.FLAG_VIRTUAL_DOCUMENT != 0 }
Java
private boolean isVirtualFile(Uri uri) { if (!DocumentsContract.isDocumentUri(this, uri)) { return false; } Cursor cursor = getContentResolver().query( uri, new String[] { DocumentsContract.Document.COLUMN_FLAGS }, null, null, null); int flags = 0; if (cursor.moveToFirst()) { flags = cursor.getInt(0); } cursor.close(); return (flags & DocumentsContract.Document.FLAG_VIRTUAL_DOCUMENT) != 0; }
After you verify that the file is virtual, you can then coerce the file into an alternative MIME type such as an image file. The following code snippet shows how to check whether a virtual file can be represented as an image, and if so, gets an input stream from the virtual file.
Kotlin
@Throws(IOException::class) private fun getInputStreamForVirtualFile(uri: Uri, mimeTypeFilter: String): InputStream { val openableMimeTypes: Array<String>? = contentResolver.getStreamTypes(uri, mimeTypeFilter) return if (openableMimeTypes?.isNotEmpty() == true) { contentResolver .openTypedAssetFileDescriptor(uri, openableMimeTypes[0], null) .createInputStream() } else { throw FileNotFoundException() } }
Java
private InputStream getInputStreamForVirtualFile(Uri uri, String mimeTypeFilter) throws IOException { ContentResolver resolver = getContentResolver(); String[] openableMimeTypes = resolver.getStreamTypes(uri, mimeTypeFilter); if (openableMimeTypes == null || openableMimeTypes.length < 1) { throw new FileNotFoundException(); } return resolver .openTypedAssetFileDescriptor(uri, openableMimeTypes[0], null) .createInputStream(); }
For more information about virtual files and how to handle them in your Storage Access Framework client app, watch the video Virtual files in the storage access framework.
For sample code related to this page, refer to:
For videos related to this page, refer to:
- DevBytes: Android 4.4 Storage Access Framework: Provider
- DevBytes: Android 4.4 Storage Access Framework: Client
- Virtual Files in the Storage Access Framework
For additional related information, refer to: