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.
What Else Is There?
Upcoming posts in this series will include:
- How to get long-term access rights, as mentioned above
- The role of
DocumentFile
- How to work with document trees from
ACTION_OPEN_DOCUMENT_TREE
- And more!
#Google #Android #Smartphones #OS #News @ndrdnws #ndrdnws #AndroidNews