Working with Content

If you have written software for other platforms, you are used to working with files on the filesystem. Android has some support for that, but it is limited and awkward — we will explore that more in an upcoming chapter.

Instead, Google would like for us to work with content more generally, whether that content comes from files, from services like Google Drive, or other places. To that end, Android comes with the Storage Access Framework for creating and consuming content.

The Storage Access Framework

Let’s think about photos for a minute.

A person might have photos managed as:

Now, let’s suppose that person is in an app that allows the user to pick a photo, such as to attach to an email or to include in an MMS message.

Somehow, that email or text messaging client needs to allow the user to choose a photo. Some developers attempt to do this by looking for photo files on the filesystem, but that will miss lots of photos, particularly on newer versions of Android. Some developers will use a class called MediaStore to query for available photos. That is a reasonable choice, but MediaStore only allows you to query for certain types of content, such as photos. If the user wants to attach a PDF to the email, MediaStore is not a great solution. Also, MediaStore only knows about local content, not items in cloud services used by the user.

The Storage Access Framework is designed to address these issues. It provides its own “picker” UI to allow users to find a file of interest that matches the MIME type that the client app wants. Document providers simply publish details about their available content — including items that may not be on the device but could be retrieved if needed. The picker UI allows for easy browsing and searching across all possible document providers, to streamline the process for the user. And, since Android is the one providing the picker, the picker should more reliably give a result to the client app based upon the user’s selection (if any).

More Dice!

The Diceware modules of the Java and Kotlin projects are based on the DiceLight sample that we saw previously. The difference is that in this version of the app, there is an overflow menu with “Open Word File”, where the user can choose a different word list to use as the source of words for the passphrases. To let the user choose the word list, we will use the Storage Access Framework.

The SAF Actions

From a programming standpoint, the Storage Access Framework feels like the “file open”, “file save-as”, and “choose directory” dialogs that you may be used to from other GUI environments. The biggest difference is that the Storage Access Framework is not limited to files.

These three bits of UI are tied to three Intent actions:

Action Equivalent Role
ACTION_OPEN_DOCUMENT file open
ACTION_CREATE_DOCUMENT file save-as
ACTION_OPEN_DOCUMENT_TREE choose directory

Those three Intent actions are designed for use with startActivityForResult(). In onActivityResult(), if we got a result, that will contain a Uri that points to a document (for ACTION_OPEN_DOCUMENT and ACTION_CREATE_DOCUMENT) or document tree (for ACTION_CREATE_DOCUMENT). We can then use that Uri to write content, read in existing content, or find child documents in a tree.

Currently, Google recommends using registerForActivityResult() with a suitable ActivityResultsContract and ActivityResultsCallback, instead of using startActivityForResult() directly. Fortunately, the Jetpack provides three contracts, one for each action:

Contract Action
ActivityResultContracts.OpenDocument ACTION_OPEN_DOCUMENT
ActivityResultContracts.CreateDocument ACTION_CREATE_DOCUMENT
ActivityResultContracts.OpenDocumentTree ACTION_OPEN_DOCUMENT_TREE

Opening a Document

Technically, we do not “open” a document using ActivityResultContracts.OpenDocument. Instead, we are requesting a Uri pointing to some document that the user chooses. So, our Diceware samples set up an openDoc object to represent that contract and request:

  private final ActivityResultLauncher<String[]> openDoc =
    registerForActivityResult(new ActivityResultContracts.OpenDocument(),
      new ActivityResultCallback<Uri>() {
        @Override
        public void onActivityResult(Uri uri) {
          motor.generatePassphrase(uri);
        }
      });
  private val openDoc = registerForActivityResult(ActivityResultContracts.OpenDocument()) {
    motor.generatePassphrase(it)
  }

Then, when the user taps on an “Open” app bar item, we call launch() on openDoc to actually trigger the request to open a document:

        openDoc.launch(new String[]{"text/plain"});
        openDoc.launch(arrayOf("text/plain"))

This time, though, the contract requires some input: an array of strings, where each string represents either a concrete MIME type (e.g. text/plain) or a wildcard MIME type (e.g., text/*). In our case, we pass in a single text/plain MIME type, limiting users to choosing that sort of file.

The user is presented with the system’s ACTION_OPEN_DOCUMENT UI to browse among various places and choose a document:

Storage Access Framework UI, Showing Documents
Storage Access Framework UI, Showing Documents

Our callbacks will receive a Uri pointing to the chosen document. We pass that to MainMotor, asking it to generate a passphrase for us, using that Uri and the user’s current requested word count.

If you want to try this yourself, you can download the diceware.wordlist.txt or eff_large_wordlist.txt word list files, put them on your test device, then open them up within the Diceware app.

Consuming the Chosen Content

MainMotor is largely the same as before. However, when we generate the passphrase, we pass the Uri along to generate() on PassphraseRepository:

package com.commonsware.jetpack.diceware;

import android.app.Application;
import android.net.Uri;
import android.text.TextUtils;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.MutableLiveData;

public class MainMotor extends AndroidViewModel {
  private static final int DEFAULT_WORD_COUNT = 6;
  private final PassphraseRepository repo;
  private Uri wordsDoc = PassphraseRepository.ASSET_URI;
  final MutableLiveData<MainViewState> viewStates =
    new MutableLiveData<>();

  public MainMotor(@NonNull Application application) {
    super(application);

    repo = PassphraseRepository.get(application);
    generatePassphrase(DEFAULT_WORD_COUNT);
  }

  void generatePassphrase() {
    final MainViewState current = viewStates.getValue();

    if (current == null) {
      generatePassphrase(DEFAULT_WORD_COUNT);
    }
    else {
      generatePassphrase(current.wordCount);
    }
  }

  void generatePassphrase(int wordCount) {
    viewStates.setValue(new MainViewState(true, null, wordCount, null));

    ListenableFuture<List<String>> future = repo.generate(wordsDoc, wordCount);

    future.addListener((Runnable)() -> {
      try {
        viewStates.postValue(new MainViewState(false,
          TextUtils.join(" ", future.get()), wordCount, null));
      }
      catch (Exception e) {
        viewStates.postValue(new MainViewState(false, null, wordCount, e));
      }
    }, Runnable::run);
  }

  void generatePassphrase(Uri wordsDoc) {
    this.wordsDoc = wordsDoc;

    generatePassphrase();
  }
}
package com.commonsware.jetpack.diceware

import android.app.Application
import android.net.Uri
import androidx.lifecycle.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

private const val DEFAULT_WORD_COUNT = 6

class MainMotor(application: Application) : AndroidViewModel(application) {
  private val _results = MutableLiveData<MainViewState>()
  val results: LiveData<MainViewState> = _results
  private var wordsDoc = ASSET_URI

  init {
    generatePassphrase(DEFAULT_WORD_COUNT)
  }

  fun generatePassphrase() {
    generatePassphrase(
      (results.value as? MainViewState.Content)?.wordCount ?: DEFAULT_WORD_COUNT
    )
  }

  fun generatePassphrase(wordCount: Int) {
    _results.value = MainViewState.Loading

    viewModelScope.launch(Dispatchers.Main) {
      _results.value = try {
        val randomWords = PassphraseRepository.generate(
          getApplication(),
          wordsDoc,
          wordCount
        )

        MainViewState.Content(randomWords.joinToString(" "), wordCount)
      } catch (t: Throwable) {
        MainViewState.Error(t)
      }
    }
  }

  fun generatePassphrase(wordsDoc: Uri) {
    this.wordsDoc = wordsDoc

    generatePassphrase()
  }
}

For our initial passphrase, though, we start off with a Uri that points to our asset:

  static final Uri ASSET_URI =
    Uri.parse("file:///android_asset/eff_short_wordlist_2_0.txt");
val ASSET_URI: Uri =
  Uri.parse("file:///android_asset/eff_short_wordlist_2_0.txt")

In PassphraseRepository, our words cache now is an LruCache. This is an Android SDK class, representing a thread-safe Map that caps its size to a certain number of entries. If we try putting more things in the cache than we have room for, the LruCache will evict the least-recently-used (“LRU”) entry. In our case, the cache is keyed by a Uri and is capped to at most four word lists:

  private final LruCache<Uri, List<String>> wordsCache = new LruCache<>(4);
  private val wordsCache = LruCache<Uri, List<String>>(4)

Then, our revised generate() function gets the cached word list by examining our cache using the supplied Uri and proceeds from there:

  ListenableFuture<List<String>> generate(Uri wordsDoc, int wordCount) {
    return CallbackToFutureAdapter.getFuture(completer -> {
      threadPool.execute(() -> {
        List<String> words;

        synchronized (wordsCache) {
          words = wordsCache.get(wordsDoc);
        }

        try {
          if (words == null) {
            InputStream in;

            if (wordsDoc.equals(ASSET_URI)) {
              in = assets.open(PassphraseRepository.ASSET_FILENAME);
            }
            else {
              in = resolver.openInputStream(wordsDoc);
            }

            words = readWords(in);
            in.close();

            synchronized (wordsCache) {
              wordsCache.put(wordsDoc, words);
            }
          }

          completer.set(rollDemBones(words, wordCount));
        }
        catch (Throwable t) {
          completer.setException(t);
        }
      });

      return "generate words";
    });
  }
  suspend fun generate(
    context: Context,
    wordsDoc: Uri,
    count: Int
  ): List<String> {
    var words: List<String>?

    synchronized(wordsCache) {
      words = wordsCache.get(wordsDoc)
    }

    return words?.let { rollDemBones(it, count, random) }
      ?: loadAndGenerate(context, wordsDoc, count)
  }

  private suspend fun loadAndGenerate(
    context: Context,
    wordsDoc: Uri,
    count: Int
  ): List<String> = withContext(Dispatchers.IO) {
    val inputStream: InputStream? = if (wordsDoc == ASSET_URI) {
      context.assets.open(ASSET_FILENAME)
    } else {
      context.contentResolver.openInputStream(wordsDoc)
    }

    inputStream?.use {
      val words = it.readLines()
        .map { line -> line.split("\t") }
        .filter { pieces -> pieces.size == 2 }
        .map { pieces -> pieces[1] }

      synchronized(wordsCache) {
        wordsCache.put(wordsDoc, words)
      }

      rollDemBones(words, count, random)
    } ?: throw IllegalStateException("could not open $wordsDoc")
  }

If we do not have our words yet, we need to load them. In DiceLight, we would always open the asset. Now, we see if the asset is the magic ASSET_URI, and we only use AssetManager and its open() function if that is the case. Otherwise, we use a ContentResolver and openInputStream() to get the content identified by the Uri that we got from ACTION_OPEN_DOCUMENT. You can get a ContentResolver by calling getContentResolver() on a Context, such as the one supplied to the repository constructor (Java) or generate() function (Kotlin).

The rest of PassphraseRepository is the same, as the rest of the code for loading words does not care whether the InputStream came from an asset or came from the ContentResolver.

DocumentFile and the Rest of the CRUD

ACTION_OPEN_DOCUMENT (and ActivityResultContracts.OpenDocument) will give you a Uri for a document that you can open for reading — the “R” in “CRUD”, as we saw in Diceware. The Storage Access Framework also supports the remaining operations: create, update, and delete.

To help you with these operations, the Jetpack offers a DocumentFile class, which provides convenience functions for finding out key details about the Uri that you received. For ACTION_OPEN_DOCUMENT and ACTION_CREATE_DOCUMENT, you can get a DocumentFile for a Uri by calling DocumentFile.fromSingleUri(), passing in that Uri. DocumentFile then has functions like getType() to tell you the MIME type associated with that particular piece of content.

Create

ACTION_CREATE_DOCUMENT will give you a Uri for a document that you can open for writing, as it is your document. Nowadays, you would use ActivityResultContracts.CreateDocument() for this.

ACTION_CREATE_DOCUMENT supports an extra, named EXTRA_TITLE, containing your desired filename or other “display name” — this does not have to be a classic filename with an extension. The launch() function for ActivityResultContracts.CreateDocument() takes a string that serves in the same role.

Note, though, that the user has the right to replace your proposed title with something else. You can find out the title for a Uri by calling getName() on a DocumentFile. Again, bear in mind that this does not have to have a classic filename structure. In particular, it does not need to have a file extension.

Update

The Uri returned from an ACTION_OPEN_DOCUMENT request may be writable; a Uri from ACTION_CREATE_DOCUMENT should be writable. You can find out by calling canWrite() on a DocumentFile for the Uri. If that returns true, you can use openOutputStream() on a ContentResolver to write to that document.

Delete

If you can write to the content, you can also delete it. To do that, call delete() on a DocumentFile for that Uri.

Getting Durable Access

By default, you will have the rights to read (and optionally write) to the document represented by the Uri until the activity that requested the document via ACTION_OPEN_DOCUMENT or ACTION_CREATE_DOCUMENT is destroyed.

If you pass the Uri to another component — such as another activity — you will need to add FLAG_GRANT_READ_URI_PERMISSION and/or FLAG_GRANT_WRITE_URI_PERMISSION to the Intent used to start that component. That extends your access until that component is destroyed. Note that fragments are all considered to be a part of the activity that created them, so you do not need to worry about extending rights from one fragment to another.

If, however, you need the rights to survive your app restarting, you can call takePersistableUriPermission() on a ContentResolver, indicating the Uri of the document and the permissions (FLAG_GRANT_READ_URI_PERMISSION and/or FLAG_GRANT_WRITE_URI_PERMISSION) that you want persisted. Then, you can save the Uri somewhere — such as in SharedPreferences, which we will explore in an upcoming chapter. Later, when your app runs again, you can get the Uri and probably still use it with ContentResolver and DocumentFile, even for a completely new activity or completely new process. Those rights even survive a reboot.

However, those rights will not survive the document being deleted or moved by some other app. You can call exists() on a DocumentFile to see if your Uri still points to a document that exists.

In addition, you can call getPersistedUriPermissions() to find out what persisted permissions your app has. This returns a List of UriPermission objects, where each one of those represents a Uri, what persisted permissions (read or write) you have, and when the permissions will expire.

Document Trees

ACTION_OPEN_DOCUMENT and ACTION_CREATE_DOCUMENT are sufficient for most apps.

However, there may be cases where you need the equivalent of a “choose directory” dialog, to allow the user to pick a location where you can create (or work with) several documents. For example, suppose that your app offers a report generator, taking data from the database and creating a report with tables and graphs and stuff. Some file formats, like PDF, might have the entire report in a single file — for that, use ACTION_CREATE_DOCUMENT to allow the user to choose where to put that report. Other file formats, like HTML, might require several files (e.g., the report body in HTML and embedded graphs in PNG format). For that, you really need a “directory”, into which you can create all of those individual bits of content.

For that, the Storage Access Framework offers document trees.

Getting a Tree

Instead of using ACTION_OPEN_DOCUMENT, you can use ACTION_OPEN_DOCUMENT_TREE… or, nowadays, ActivityResultContracts.OpenDocumentTree. You will get back a Uri that represents the tree. You should have full read/write access not only to this tree but to anything inside of it.

Working in the Tree

The simplest approach for then working with the tree is to use the aforementioned DocumentFile wrapper. You can create one representing the tree by using the fromTreeUri() static method, passing in the Uri that you got from the ACTION_OPEN_DOCUMENT_TREE request.

From there, you can:

Note that you can call takePersistableUriPermission() on a ContentResolver to try to have durable access to the document tree, just as you can for a Uri to an individual document.


Prev Table of Contents Next

This book is licensed under the Creative Commons Attribution-ShareAlike 4.0 International license.