Scoped Storage Stories: SAF Basics
Android 10 is greatly restricting access to external storage via filesystem APIs. Instead, we need to use other APIs to work with content. This is the first post in a series where we will explore how to work with those alternatives, starting with the Storage Access Framework (SAF).
The Storage Access Framework operates on the same principles of file-selection UIs that users have been using for decades:
-
We have a way to ask the user to choose an existing file or other piece of content (
ACTION_OPEN_DOCUMENT
) -
We have a way to ask the user to choose where we can place a new piece of content that our app will create (
ACTION_CREATE_DOCUMENT
) -
We have a way to ask the user to choose an existing directory or other form of “document tree” that we can use for working with multiple documents and sub-trees (
ACTION_OPEN_DOCUMENT_TREE
)
The first two have been available since Android 4.4; ACTION_OPEN_DOCUMENT_TREE
was added in Android 5.1. The vast majority of Android devices in use today have
access to these actions.
As these symbols’ names suggest, they are action strings for use in implicit
Intent
construction. And, since we are going to be bringing up UI for the user
to choose things, we will use these Intent
objects to start activities. In particular,
these Intent
actions are designed for use with startActivityForResult()
,
so we get the results of the user’s selection.
Choosing Via ACTION_OPEN_DOCUMENT
So, if you want to ask the user to choose a file or piece of content,
use ACTION_OPEN_DOCUMENT
:
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
.setType("text/*")
.addCategory(Intent.CATEGORY_OPENABLE)
startActivityForResult(intent, REQUEST_SAF)
Here, we use setType()
to indicate the MIME type of the content that we are seeking
— in this case, something that is text-based. Wildcard MIME types are fine,
but bear in mind that there is no absolute guarantee that the content that the user
chooses will be actually of that MIME type. The SAF content-chooser UI will try
to filter out incompatible stuff, but it has no way to know that some file named
this_is_not_text.txt
is really some cat GIF that got renamed with a .txt
file
extension.
The addCategory(Intent.CATEGORY_OPENABLE)
part of the Intent
configuration
says “um, yeah, we’d really kinda like to actually work with this content”.
In particular, you should be guaranteed getting a piece of content that you can
open an InputStream
on. You would think that this would be the default behavior,
but it is not, so we need to add the category to help ensure that things work
as expected.
Creating via ACTION_CREATE_DOCUMENT
ACTION_OPEN_DOCUMENT
will let you read in existing content. ACTION_CREATE_DOCUMENT
will let you create new content. In desktop environments, ACTION_OPEN_DOCUMENT
is the “file open” dialog, while ACTION_CREATE_DOCUMENT
is the “file save-as”
dialog.
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
.setType("text/plain")
.addCategory(Intent.CATEGORY_OPENABLE)
startActivityForResult(intent, REQUEST_SAF)
The code to make the request is basically the same, with two tweaks:
-
We use
ACTION_CREATE_DOCUMENT
-
We use a concrete MIME type
In Android, the basic MIME type rules are pretty simple:
-
If you are requesting content from something, you may be able to use a wildcard MIME type
-
If you are providing content to something, you need to use a concrete MIME type, as it is your content and you need to be specifying what sort of content it is
Getting the Result
Your startActivityForResult()
call will eventually trigger an onActivityResult()
callback. There, if the result is RESULT_OK
, you can get a Uri
that represents
the chosen location:
override fun onActivityResult(
requestCode: Int,
resultCode: Int,
data: Intent?
) {
if (requestCode == REQUEST_SAF) {
if (resultCode == RESULT_OK && data != null) {
data.data?.let { uri -> TODO("do something with the Uri") }
}
} else {
super.onActivityResult(requestCode, resultCode, data)
}
}
Reading From the Uri
We can then use a ContentResolver
to read in any existing content at that
Uri
, for the ACTION_OPEN_DOCUMENT
scenario:
suspend fun read(context: Context, source: Uri): String = withContext(Dispatchers.IO) {
val resolver: ContentResolver = context.contentResolver
resolver.openInputStream(source)?.use { stream -> stream.readText() }
?: throw IllegalStateException("could not open $source")
}
private fun InputStream.readText(charset: Charset = Charsets.UTF_8): String =
readBytes().toString(charset)
You get a ContentResolver
from any Context
via its getContentResolver()
method (here mapped to a contentResolver
Kotlin property). openInputStream()
will attempt to open an InputStream
on the supplied Uri
. That InputStream
works similarly to the FileInputStream
that you might have used for a plain
Java File
. Here, we read in all of the text from the content identified
by the Uri
. This read()
function will throw an exception, either on its
own (if openInputStream()
returns null
) or if something we call throws
an exception (e.g., openInputStream()
might throw FileNotFoundException
).
In this case, all of our work is wrapped in a CoroutineContext
tied to
Dispatchers.IO
, so we can get this I/O work onto a background thread supplied
by Kotlin’s coroutines system.
Writing to the Uri
For either ACTION_OPEN_DOCUMENT
or ACTION_CREATE_DOCUMENT
, you can write
content to the location identified by the Uri
, using similar code:
suspend fun write(context: Context, source: Uri, text: String) = withContext(Dispatchers.IO) {
val resolver: ContentResolver = context.contentResolver
resolver.openOutputStream(source)?.use { stream -> stream.writeText(text) }
?: throw IllegalStateException("could not open $source")
}
private fun OutputStream.writeText(
text: String,
charset: Charset = Charsets.UTF_8
): Unit = write(text.toByteArray(charset))
Just as ContentResolver
has openInputStream()
, it has openOutputStream()
.
You get an OutputStream
that you can use to write out content, such as by using
the writeText()
extension function on OutputStream
shown in the code snippet.
Uri
Usage Rules
The Uri
that we get from these actions will have a content
scheme. In
general, with such a Uri
, you do not want to make any assumptions about
it — treat it as an opaque identifier, nothing more.
The Uri
is a bit like an HTTPS URL to a password-protected Web page: you have
limited time that you can access the content identified by the Uri
. The
default behavior is that your activity that was responsible for the
startActivityForResult()
call can use that Uri
, but other components of
your app (other activities, services, etc.) cannot use it, and you cannot use
it after this activity instance is destroyed. An upcoming blog post in this
series will cover getting long-term access rights to the content, for cases
where you might need it.
The entire series of “Scoped Storage Stories” posts includes posts on:
- The basics of using the Storage Access Framework
- Getting durable access to the selected content
- Working with
DocumentFile
for individual documents - Working with document trees
- Working with
DocumentsContract
- Problems with the SAF API
- A specific problem with
listFiles()
onDocumentFile
- Storing content using
MediaStore
- Reading content from the
MediaStore
- Modifying
MediaStore
content from other apps - Limitations of
MediaStore.Downloads
- The undocumented
Documents
option - More on
RecoverableSecurityException
- How to modify more metadata in
MediaStore