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:
- on-device photos, mediated by an app like a gallery
- photos stored online in a photo-specific service, like Instagram
- photos stored online in a generic file-storage service, like Google Drive or Dropbox
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:
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:
- Call
listFiles()
to get the immediate children of the root of this tree, getting back an array ofDocumentFile
objects representing those children - Call
isDirectory()
to confirm that you do indeed have a tree (or, call it on a child to see if that child represents a sub-tree) - For those existing children that are files (
isFile()
returnstrue
), usegetUri()
to get theUri
for this child, so you can read its contents using aContentResolver
andopenInputStream()
- Call
createDirectory()
orcreateFile()
to add new content as an immediate child of this tree, getting aDocumentFile
as a result - For the
createFile()
scenario, callgetUri()
on theDocumentFile
to get aUri
that you can use for writing out the content usingContentResolver
andopenOutputStream()
- and so on
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.