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:

  1. We use ACTION_CREATE_DOCUMENT

  2. 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: