Consuming Documents

Android has long offered the ability for an app to pick some file or stream from another app and consume it. However, the original options were designed around an app loading content from another app. Even though our code would be requesting content based on abstractions like MIME types, the implementation and user experience would be based on the traditional “pick an app to fulfill this request” chooser.

Google, given its clear interest in cross-cutting storage engines like Google Drive, wanted something better. In Android 4.4, they added the Storage Access Framework (SAF) to provide a better user experience, with only modest changes to client code. With Android’s increasing reliance upon content and document providers for cross-app content sharing, understanding the Storage Access Framework is fairly important for modern app development.

In this chapter, we will examine what it takes to consume documents published via the SAF.

Prerequisites

This chapter assumes that you have read the chapter on ContentProvider patterns or have equivalent experience with consuming streams published by a ContentProvider.

The Storage Access… What?

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.

The classic Android solution would be for the user to have to first choose the app to use to find the photo (e.g., Gallery, Instagram, Google Drive, Dropbox), then find the photo using that app. Then, if all goes well, the original app would receive a Uri to that photo and be able to make use of it.

However, this flow has three main problems:

  1. From the user’s standpoint, they need to know where they have the photo before they can go looking for it. Given the prominence of generic file-storage services, the user might not remember where the photo is stored, but might remember enough details about the photo (e.g., timeframe when taken, tags that might have been attached to the photo) to find it… but the user has to sequentially search each possible photo-storing app until the right one is found.
  2. From the client app developer’s standpoint, too many apps screw up handling the classic ACTION_PICK and ACTION_GET_CONTENT activities, failing to return a result in all cases. Users then are as likely to blame the client app for the mistake as they are to blame the photo-storing app or Android itself.
  3. None of this was designed with online file-sharing services in mind. What happens if an app knows about a possible file, but the file is not available on the device right now, because it has not been downloaded from the online service?

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. File providers simply publish details about their available files — including those 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 file 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).

The Storage Access Framework Participants

Providers are specialized ContentProvider implementations, usually extending DocumentsProvider, that can tell Android about the documents that are published by an app. This includes providing any sort of organizational structure (directory tree, tag cloud, etc.)

The clients are apps that wish to consume (or create) documents managed by providers. Clients will indicate what sort of document they want, in the form of a MIME type, where applicable.

The picker is the system UI that allows the user to pick a document (or documents) from among the documents published by all providers that meet the criteria established by a client requesting access to the document(s).

Picking How to Pick (a Peck of Pickled Pepper Photos)

ACTION_PICK would seem to be the Intent action to use to pick something. It works, but it is designed for the case where you know the specific collection of “somethings” you want to pick from. Use this, for example, to pick a contact specifically out of ContactsContract.

In cases where you know the MIME type you want, but you do not particularly know or care about the exact source of the file, use ACTION_GET_CONTENT on API Level 18 and below for everything.

For MIME types that clearly represent a document, file, or other sort of stream, use ACTION_OPEN_DOCUMENT (and the SAF) on API Level 19+. The SAF picker will incorporate both full-fledged SAF-compliant providers’ documents along with apps that only support ACTION_GET_CONTENT. However, since ACTION_OPEN_DOCUMENT is only available on API Level 19+ devices, if you are supporting older devices, you will need to check Build.VERSION.SDK_INT and choose an Intent action accordingly.

For MIME types that represent entries in a database (e.g., a calendar entry), use ACTION_GET_CONTENT, even on API Level 19+. Google also recommends using ACTION_GET_CONTENT on API Level 19+ “if you want your app to simply read/import data”, though it is unclear why they make this recommendation or why the user experience should differ based upon how the bytes would be used.

Opening a Document

Technically, we do not “open” a document using ACTION_OPEN_DOCUMENT. Instead, we are requesting a Uri pointing to some document that the user chooses.

To do that, create an Intent with:

Then, use that Intent with startActivityForResult().

For example, the Documents/Consumer sample application contains a ConsumerFragment that adds an “Open” item to the action bar overflow. Clicking on “Open” triggers a call to the open() method on the fragment. And, for API Level 19+ devices, that will in turn request to “open” a document:

  @TargetApi(Build.VERSION_CODES.KITKAT)
  private void open() {
    Intent i=new Intent().setType("image/*");

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
      startActivityForResult(i.setAction(Intent.ACTION_OPEN_DOCUMENT)
                              .addCategory(Intent.CATEGORY_OPENABLE),
                             REQUEST_OPEN);
    }
    else {
      startActivityForResult(i.setAction(Intent.ACTION_GET_CONTENT)
                              .addCategory(Intent.CATEGORY_OPENABLE),
                             REQUEST_GET);
    }
  }
(from Documents/Consumer/app/src/main/java/com/commonsware/android/documents/consumer/ConsumerFragment.java)

This open() method also gracefully degrades for older devices, falling back to ACTION_GET_CONTENT. In both cases, we are trying to allow the user to pick some image (MIME type of image/*). The two startActivityForResult() calls use different request IDs (REQUEST_OPEN versus REQUEST_GET), so that we can distinguish the sort of result that we get in onActivityResult():

  @Override
  public void onActivityResult(int requestCode, int resultCode,
                               Intent resultData) {
    if (resultCode==Activity.RESULT_OK) {
      if (resultData!=null) {
        uri=resultData.getData();

        if (requestCode==REQUEST_OPEN) {
          getLoaderManager().initLoader(0, null, this);
        }
        else {
          logToTranscript(uri.toString());
        }
      }
    }
  }
(from Documents/Consumer/app/src/main/java/com/commonsware/android/documents/consumer/ConsumerFragment.java)

Both ACTION_GET_CONTENT and ACTION_OPEN_DOCUMENT should supply a Uri in the result Intent that points to the document the user chose, if the user actually chose one and we got RESULT_OK as the result code. This sample logs that Uri value to a “transcript” (TextView inside of a ScrollView) to show what we get back.

If the result is from an ACTION_OPEN_DOCUMENT request (REQUEST_OPEN request code), we can try to get some metadata about the document. The provider should support a query on the returned Uri that will give us the display name (OpenableColumns.DISPLAY_NAME) and possibly the size of the file (OpenableColumns.SIZE). So, we use the Loader framework to run this query, with our fragment implementing the LoaderCallbacks:

  public Loader<Cursor> onCreateLoader(int loaderId, Bundle args) {
    return(new CursorLoader(getActivity(), uri, PROJECTION, null, null, null));
  }

  public void onLoadFinished(Loader<Cursor> loader, Cursor c) {
    transcript.setText(null);
    logToTranscript(uri.toString());

    if (c!=null && c.moveToFirst()) {
      int displayNameColumn=
        c.getColumnIndex(OpenableColumns.DISPLAY_NAME);

      if (displayNameColumn>=0) {
        logToTranscript("Display name: "
          +c.getString(displayNameColumn));
      }

      int sizeColumn=c.getColumnIndex(OpenableColumns.SIZE);

      if (sizeColumn<0 || c.isNull(sizeColumn)) {
        logToTranscript("Size not available");
      }
      else {
        logToTranscript(String.format("Size: %d",
          c.getInt(sizeColumn)));
      }
    }
    else {
      logToTranscript("...no metadata available?");
    }
  }

  public void onLoaderReset(Loader<Cursor> loader) {
    // unused
  }
(from Documents/Consumer/app/src/main/java/com/commonsware/android/documents/consumer/ConsumerFragment.java)

If we get results back, we try to read out these two values and record them to the transcript as well. Note, though, that:

The user is presented with the system’s picker, to choose an image, complete with a navigation drawer to get to various spots within the picker:

Storage Access Framework Picker, Showing Images
Figure 753: Storage Access Framework Picker, Showing Images

When the user taps on an image, the results wind up in our transcript UI:

Uri, Display Name, and Size of Chosen File
Figure 754: Uri, Display Name, and Size of Chosen File

Note that the default behavior of ACTION_OPEN_DOCUMENT is to let the user choose a single file. If your Intent includes EXTRA_ALLOW_MULTIPLE, set to true, then the user can choose multiple documents. Rather than getting their Uri values via getData() on the result Intent, you will need to call getClipData() on the Intent and iterate over the “clipboard entries”.

The Uri itself can then be used to get an InputStream or OutputStream for the contents, using openInputStream() and openOutputStream() on ContentResolver, respectively. Note, though, that you cannot pass the Uri to other applications, as they may not have rights to work with that document the way that you do.

Why We Want Things To Be Openable

You will notice that both the ACTION_OPEN_DOCUMENT and the ACTION_GET_CONTENT Intent objects created in the preceding example have CATEGORY_OPENABLE applied to them. This is supposed to guarantee that we can actually consume the content represented by the Uri that we get. In particular, we should be able to use a ContentResolver and open streams on that content.

If we leave off CATEGORY_OPENABLE, it is possible that we will get a Uri that we cannot open ourselves.

The difference boils down to what use we intend to put the Uri toward:

The Rest of the CRUD

ACTION_OPEN_DOCUMENT will give you a Uri for a document that you can open for reading — the “R” in “CRUD”.

However, the other CRUD operations are also entirely possible.

Create

ACTION_CREATE_DOCUMENT will give you a Uri for a document that you can open for writing, as it is your document.

To do this, construct an Intent with:

Then, invoke startActivityForResult() on that Intent, and use the Uri supplied in the result Intent delivered to onActivityResult().

Update

The Uri returned from an ACTION_OPEN_DOCUMENT request may be writable. If it is, you can use openOutputStream() on a ContentResolver to write to that document. You can determine if a document is writable by examining the COLUMN_FLAGS value returned from a query() on the Uri — if it includes FLAG_SUPPORTS_WRITE, you can write to the document.

Delete

Similarly, if the COLUMN_FLAGS value includes FLAG_SUPPORTS_DELETE, you can delete the document by calling the static deleteDocument() method on the DocumentsContract class, supplying a ContentResolver plus the Uri of the document to be deleted.

The DocumentFile Helper

The support-core-utils library from the Android Support libraries contains a DocumentFile class. This provides a light File-like layer atop the low-level API offered through a ContentResolver or DocumentsContract. Of note, DocumentFile offers convenience methods for:

DocumentFile can actually work with raw files — just create a DocumentFile using the static fromFile() method. More often, though, you will create a DocumentFile wrapped around the Uri that you get from ACTION_OPEN_DOCUMENT or ACTION_CREATE_DOCUMENT. For that, there is the static fromSingleUri() method.

CWAC-Document and DocumentFileCompat

There is one significant problem with DocumentFile, though: it only works on document Uri values on API Level 19 and higher. However, we can get such Uri values on older devices, and we can get Uri values from other places than a DocumentProvider (e.g., FileProvider).

The CWAC-Document library offers a DocumentFileCompat class. This is forked from the official DocumentFile but adds support for older devices and non-document Uri values. Specifically, for a non-document content Uri, you can get:

Also, some DocumentFile methods whose values can be safely hard-coded for legacy documents, like isVirtual() (false), isDirectory() (false), and isFile() (true) are supported.

DocumentFileCompat also offers convenience methods for:

We will see the use of DocumentFileCompat in the next couple of sample apps.

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 a service – 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.

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. Those rights will then survive a reboot. However:

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.

If you do not have persistable permissions when you get the Uri, your primary recourse is to make a local copy of the content, while you still have temporary access to it. The Documents/Durable sample application illustrates this. It is based on the Documents/Consumer sample app from earlier in this chapter, with a few key changes:

For an event bus, we use greenrobot’s EventBus, added as a dependency via build.gradle, along with the CWAC-Document library:

apply plugin: 'com.android.application'

android {
    compileSdkVersion 27
    buildToolsVersion '27.0.3'

    defaultConfig {
        minSdkVersion 15
        targetSdkVersion 27
        applicationId "com.commonsware.android.documents.durable"
    }
}

repositories {
    maven {
        url "https://s3.amazonaws.com/repo.commonsware.com"
    }
}

dependencies {
    implementation 'com.android.support:support-fragment:27.1.0'
    implementation 'org.greenrobot:eventbus:3.0.0'
    implementation 'com.commonsware.cwac:document:0.2.0'
}
(from Documents/Durable/app/build.gradle)

MainActivity is the same as in the Documents/Consumer sample app, as is the core UI of ConsumerFragment (ScrollView with a TextView inside). However, ConsumerFragment registers for events in onStart() and unregisters in onStop():

  @Override
  public void onStart() {
    super.onStart();

    EventBus.getDefault().register(this);
  }

  @Override
  public void onStop() {
    EventBus.getDefault().unregister(this);

    super.onStop();
  }
(from Documents/Durable/app/src/main/java/com/commonsware/android/documents/consumer/ConsumerFragment.java)

The menu resource now has two items, open (for ACTION_OPEN_DOCUMENT) and get (for ACTION_GET_CONTENT):

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">

  <item
    android:id="@+id/get"
    android:showAsAction="never"
    android:title="@string/get" />
  <item
    android:id="@+id/open"
    android:enabled="false"
    android:showAsAction="never"
    android:title="@string/open" />

</menu>
(from Documents/Durable/app/src/main/res/menu/actions.xml)

The open one is disabled (android:enabled="false") at the outset, as ACTION_OPEN_DOCUMENT only works on API Level 19+ devices, and the minSdkVersion of this sample is 15.

In onCreateOptionsMenu(), we conditionally enable the open item, and in onOptionsItemSelected(), we route the menu items to open() and get() methods:

  @Override
  public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
    inflater.inflate(R.menu.actions, menu);

    if (Build.VERSION.SDK_INT>=Build.VERSION_CODES.KITKAT) {
      menu.findItem(R.id.open).setEnabled(true);
    }

    super.onCreateOptionsMenu(menu, inflater);
  }

  @Override
  public boolean onOptionsItemSelected(MenuItem item) {
    if (item.getItemId()==R.id.open) {
      open();
    }
    else if (item.getItemId()==R.id.get) {
      get();
    }

    return(super.onOptionsItemSelected(item));
  }
(from Documents/Durable/app/src/main/java/com/commonsware/android/documents/consumer/ConsumerFragment.java)

Those methods invoke their associated Intent actions, using */* this time for the MIME type:

  @TargetApi(Build.VERSION_CODES.KITKAT)
  private void open() {
    Intent i=
      new Intent()
        .setType("*/*")
        .setAction(Intent.ACTION_OPEN_DOCUMENT)
        .addCategory(Intent.CATEGORY_OPENABLE);

    startActivityForResult(i, REQUEST_OPEN);
  }

  private void get() {
    Intent i=
      new Intent()
        .setType("image/png")
        .setAction(Intent.ACTION_GET_CONTENT)
        .addCategory(Intent.CATEGORY_OPENABLE);

    startActivityForResult(i, REQUEST_GET);
  }
(from Documents/Durable/app/src/main/java/com/commonsware/android/documents/consumer/ConsumerFragment.java)

However, whereas the Documents/Consumer sample app processed the resulting Uri directly in onActivityResult(), now we delegate that work to a DurablizerService that is responsible for ensuring that we have durable access to the content:

  @Override
  public void onActivityResult(int requestCode, int resultCode,
                               Intent resultData) {
    if (resultCode==Activity.RESULT_OK) {
      getActivity()
        .startService(new Intent(getActivity(), DurablizerService.class)
          .setData(resultData.getData())
          .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION));
    }
  }
(from Documents/Durable/app/src/main/java/com/commonsware/android/documents/consumer/ConsumerFragment.java)

We pass the Uri via the data facet of the Intent (setData()), plus add the FLAG_GRANT_READ_URI_PERMISSION flag. This ensures that DurablizerService will have access to the content even if MainActivity is destroyed before our work is complete.

onHandleIntent() in DurablizerService has a basic flow:

  @Override
  protected void onHandleIntent(Intent intent) {
    Uri document=intent.getData();
    boolean weHaveDurablePermission=obtainDurablePermission(document);

    if (!weHaveDurablePermission) {
      document=makeLocalCopy(document);
    }

    if (weHaveDurablePermission || document!=null) {
      Log.d(getClass().getSimpleName(), document.toString());

      DocumentFileCompat docFile=buildDocFileForUri(document);

      Log.d(getClass().getSimpleName(),
        "Display name: "+docFile.getName());
      Log.d(getClass().getSimpleName(),
        "Size: "+Long.toString(docFile.length()));

      EventBus.getDefault().post(new ContentReadyEvent(docFile));
    }
  }
(from Documents/Durable/app/src/main/java/com/commonsware/android/documents/consumer/DurablizerService.java)

The job of obtainDurablePermission() is to use takePersistableUriPermission() on a ContentResolver to request read and write access, at least on Android 4.4 or higher. So, we create a perms value that requests both FLAG_GRANT_READ_URI_PERMISSION and FLAG_GRANT_WRITE_URI_PERMISSION, and pass that to takePersistableUriPermission(), along with the document Uri:

  private boolean obtainDurablePermission(Uri document) {
    boolean weHaveDurablePermission=false;

    if (Build.VERSION.SDK_INT>=Build.VERSION_CODES.KITKAT) {
      int perms=Intent.FLAG_GRANT_READ_URI_PERMISSION
        | Intent.FLAG_GRANT_WRITE_URI_PERMISSION;

      try {
        getContentResolver()
          .takePersistableUriPermission(document, perms);

        for (UriPermission perm :
          getContentResolver().getPersistedUriPermissions()) {
          if (perm.getUri().equals(document)) {
            weHaveDurablePermission=true;
          }
        }
      }
      catch (SecurityException e) {
        // OK, we were not offered any persistable permissions
      }
    }

    return(weHaveDurablePermission);
  }
(from Documents/Durable/app/src/main/java/com/commonsware/android/documents/consumer/DurablizerService.java)

Unfortunately, while takePersistableUriPermission() is synchronous, it does not actually tell us if we have those permissions. The only way to find out if we have access is to call getPersistedUriPermissions(), which returns a roster of UriPermission objects listing every persisted Uri permission held by our app. Then, we have to sift through those, looking for one matching our desired document Uri.

obtainDurablePermission() then returns a boolean indicating whether or not our request for persistable permissions succeeded.

makeLocalCopy() will be called if obtainDurablePermission() returns false (e.g., we are on an older Android device or otherwise used ACTION_GET_CONTENT). Its job is to make a local copy of the content, so we have indefinite access:

  private Uri makeLocalCopy(Uri document) {
    DocumentFileCompat docFile=buildDocFileForUri(document);
    Uri result=null;

    if (docFile.getName()!=null) {
      try {
        String ext=
          MimeTypeMap.getSingleton().getExtensionFromMimeType(docFile.getType());

        if (ext!=null) {
          ext="."+ext;
        }

        File f=File.createTempFile("cw_", ext, getFilesDir());

        docFile.copyTo(f);
        result=Uri.fromFile(f);
      }
      catch (Exception e) {
        Log.e(getClass().getSimpleName(),
          "Exception copying content to file", e);
      }
    }

    return(result);
  }
(from Documents/Durable/app/src/main/java/com/commonsware/android/documents/consumer/DurablizerService.java)

We start off by getting a DocumentFileCompat for the document Uri, using a buildDocFileForUri() helper method:

  private DocumentFileCompat buildDocFileForUri(Uri document) {
    DocumentFileCompat docFile;

    if (document.getScheme().equals(ContentResolver.SCHEME_CONTENT)) {
      docFile=DocumentFileCompat.fromSingleUri(this, document);
    }
    else {
      docFile=DocumentFileCompat.fromFile(new File(document.getPath()));
    }

    return(docFile);
  }
(from Documents/Durable/app/src/main/java/com/commonsware/android/documents/consumer/DurablizerService.java)

This handles both likely scenarios:

Back in makeLocalCopy(), we get the file extension associated with the content’s MIME type, by way of MimeTypeMap and getExtensionFromMimeType(). Calling getType() on the DocumentFileCompat will fail if, for some reason, we have a content: Uri but do not have any permission to work with it. For example, had we failed to include FLAG_GRANT_READ_URI_PERMISSION on the Intent that started DurablizerService and if the MainActivity were destroyed by this point, we would not have any rights to use the content, and getType() will fail to get the MIME type for the content.

But, if getType() succeeds, we try to get that file extension. That too might fail, returning null, if the MIME type is not recognized by MimeTypeMap. We can live with a null file extension. However, if the file extension is not null, it will lack the leading ., so we add that. Then, we create a unique file in getFilesDir(), using that file extension and createTempFile() on File. From there, we use the convenient copyTo() method on DocumentFileCompat to copy that content into our temporary file.

The ContentReadyEvent simply wraps a DocumentFileCompat pointing to our final content: either the original Uri (if we obtained durable access to it) or on our local copy:

  static class ContentReadyEvent {
    final DocumentFileCompat docFile;

    ContentReadyEvent(DocumentFileCompat docFile) {
      this.docFile=docFile;
    }
  }
(from Documents/Durable/app/src/main/java/com/commonsware/android/documents/consumer/DurablizerService.java)

Back in ConsumerFragment, when the ContentReadyEvent is received, we log the details to the transcript:

  @Subscribe(threadMode=ThreadMode.MAIN)
  public void onContentReady(DurablizerService.ContentReadyEvent event) {
    logToTranscript(event.docFile.getUri().toString());
    logToTranscript("Display name: "+event.docFile.getName());
    logToTranscript("Size: "+Long.toString(event.docFile.length()));
  }
(from Documents/Durable/app/src/main/java/com/commonsware/android/documents/consumer/ConsumerFragment.java)

For cases where we are granted persistable permissions, the output will show a content: Uri, as we can continue to use the original content:

Durable Document Demo, Showing Document Result
Figure 755: Durable Document Demo, Showing Document Result

For cases where we were not granted persistable permissions — such as ACTION_GET_CONTENT — the output will show a file: Uri, representing the local copy of the content:

Durable Document Demo, Showing Content Result
Figure 756: Durable Document Demo, Showing Content Result

Another Durable Example: Diceware

Dealing with durable documents does not require a service. Services are a good idea when you might need to make a copy of the document (e.g., the ACTION_GET_CONTENT scenario) and you do not know how big that document might be. In cases where the document is known to be small, just using background threads is fine, such as via RxJava.

With that in mind, here is another sample app that demonstrates using ACTION_OPEN_DOCUMENT and ACTION_GET_CONTENT to work with user-provided documents. Unlike most examples in this book, though, this app has some direct usefulness, as it is a passphrase generator, using the diceware approach.

Dice? Where?!?

Simply put, diceware is a technique for generating a lengthy passphrase that still has a chance of being memorable, by choosing words from a list of common words.

The name “diceware” comes from the canonical way of generating the passphrase: rolling five six-sided dice (the sorts of dice that you see in casinos and board games) and looking up words in a word list. The more randomly-chosen words in the passphrase, the stronger the passphrase and the more time it would take for it to be cracked by somebody.

Perhaps the most famous such passphrase is correct horse battery staple, though a four-word passphrase is probably a bit short today.

We Want Words!

To use the diceware approach to generate a passphrase, you need a list of words.

A traditional diceware word list contains two columns. The right-hand column is the word, and the left-hand column is the dice roll that corresponds to that word: 4244 liverwurst 4245 lizard 4246 llama 4251 luau 4252 lubricant 4253 lucidity 4254 ludicrous 4255 luggage

The canonical diceware site has a number of word lists for a variety of languages. Each of those lists has 7,776 words, which equates to 65, or the number of combinations of five rolls of a six-sided die.

However, other word lists also exist. The EFF has published a few lists, aiming for more commonly-used words and eliminating ones that might cause confusion (e.g., homophones). They also have some short lists, with only 1,296 (64) words, with an emphasis on shorter words. Shorter word lists require more words in the passphrase to get the same level of security (e.g., 8 words from the short lists result in similar security as 6 words from the longer lists).

The Documents/Diceware sample application packages one of the EFF word lists in the app itself, as an asset, so the app is usable “out of the box” to generate a passphrase. However, it also has action bar items to allow you to use ACTION_OPEN_DOCUMENT or ACTION_GET_CONTENT to use a different word list, if you wanted.

The Results

First, let’s take a look at what the app does, before we see how it is built.

When you run the app, you immediately get a CardView showing a randomly-generated passphrase, using the word list that is baked into the app:

Diceware App, As Initially Launched
Figure 757: Diceware App, As Initially Launched

Clicking the refresh action bar item will generate a fresh passphrase, while tapping the “Words” action bar item lets you choose the length of the passphrase (from 4-10 words):

Diceware App, Showing Word Count Submenu
Figure 758: Diceware App, Showing Word Count Submenu

There are three options in the action bar overflow:

How We Got There

The Diceware app consists of a single PassphraseFragment, loaded using a FragmentTransaction by the MainActivity.

Loading Our Words

PassphraseFragment uses two RxJava Observable objects:

When the app first runs, neither of these are set up yet. In onViewCreated(), we confirm that we do not have a docObservable at the moment — if we did, that would indicate that we are still in the middle of getting durable access to some words. Normally, docObservable will be null here, and so we call a loadWords() method:

  @Override
  public void onViewCreated(View view, Bundle savedInstanceState) {
    super.onViewCreated(view, savedInstanceState);

    passphrase=view.findViewById(R.id.passphrase);

    if (savedInstanceState!=null) {
      wordCount=savedInstanceState.getInt(STATE_WORD_COUNT);
    }

    if (docObservable!=null) {
      docSub();
    }
    else {
      loadWords(false, wordsObservable==null);
    }
  }
(from Documents/Diceware/app/src/main/java/com/commonsware/android/diceware/PassphraseFragment.java)

(we will cover docObservable, docSub(), and wordCount later)

loadWords() takes two boolean parameters:

PassphraseFragment is a retained fragment. It is entirely possible that we already have our words loaded from disk, in which case we do not need to load them again (e.g., after the user rotated the screen). So, here we pass false for the first parameter.

However, just because the user rotated the screen does not mean that we want to generate a fresh passphrase. We only want to do that if we do not already have a passphrase, or have one being generated right now. If wordsObservable exists (is not null), then we know that a passphrase has either been displayed or is soon to be displayed, and so we can skip creating a new one. When the app is first launched, though, wordsObservable is null, and so we tell loadWords() to generate a passphrase.

loadWords() then sets up wordsObservable:

  private void loadWords(boolean forceReload, boolean regenPassphrase) {
    if (wordsObservable==null || forceReload) {
      final Application app=getActivity().getApplication();

      wordsObservable=Observable
        .defer(() -> (Observable.just(PreferenceManager
          .getDefaultSharedPreferences(app))))
        .subscribeOn(Schedulers.io())
        .map(sharedPreferences -> {
          PassphraseFragment.this.prefs=sharedPreferences;

          return(sharedPreferences.getString(PREF_URI, ""));
        })
        .map(s -> {
          InputStream in;

          if (s.length()==0) {
            in=app.getAssets().open(ASSET_FILENAME);
          }
          else {
            in=app.getContentResolver().openInputStream(Uri.parse(s));
          }

          return(readWords(in));
        })
        .cache()
        .observeOn(AndroidSchedulers.mainThread());
    }

    unsubWords();

    if (regenPassphrase) {
      wordsSub=wordsObservable.subscribe(this::rollDemBones, error -> {
          Toast
            .makeText(getActivity(), error.getMessage(), Toast.LENGTH_LONG)
            .show();
          Log.e(getClass().getSimpleName(), "Exception processing request",
            error);
      });
    }
  }
(from Documents/Diceware/app/src/main/java/com/commonsware/android/diceware/PassphraseFragment.java)

We only need to set up wordsObservable if it does not already exist or forceReload is true. In either of those cases, we:

readWords() simply reads in the lines from the asset or external word list, divides each line on the whitespace between the two columns, and extracts the word from the second column:

  private static List<String> readWords(InputStream in) throws IOException {
    InputStreamReader isr=new InputStreamReader(in);
    BufferedReader reader=new BufferedReader(isr);
    String line;
    List<String> result=new ArrayList<>();

    while ((line = reader.readLine())!=null) {
      String[] pieces=line.split("\s");

      if (pieces.length==2) {
        result.add(pieces[1]);
      }
    }

    return(result);
  }
(from Documents/Diceware/app/src/main/java/com/commonsware/android/diceware/PassphraseFragment.java)

loadWords() then calls unsubWords(). This will dispose() any existing subscription to wordsObservable:

  private void unsubWords() {
    if (wordsSub!=null && !wordsSub.isDisposed()) {
      wordsSub.dispose();
    }
  }
(from Documents/Diceware/app/src/main/java/com/commonsware/android/diceware/PassphraseFragment.java)

Then, if we need a passphrase (regenPassphrase is true), we subscribe() to the wordsObservable, routing the word list to a rollDemBones() method, and displaying a Toast and logging to Logcat in case there is some sort of error (e.g., IOException reading in the words).

rollDemBones() uses SecureRandom to pick wordCount words from the list and updates the passphrase TextView with those words:

  private void rollDemBones(List<String> words) {
    StringBuilder buf=new StringBuilder();
    int size=words.size();

    for (int i=0;i<wordCount;i++) {
      if (buf.length()>0) {
        buf.append(' ');
      }

      buf.append(words.get(random.nextInt(size)));
    }

    passphrase.setText(buf.toString());
  }
(from Documents/Diceware/app/src/main/java/com/commonsware/android/diceware/PassphraseFragment.java)

The net result, after all of that, is that when we first run the app, we create and subscribe to the wordsObservable, and after a brief bit of I/O, we show the initial passphrase to the user.

Getting More Words

Handling the ACTION_GET_CONTENT and ACTION_OPEN_DOCUMENT scenarios is reminiscent of the Documents/Durable sample from before.

The two action bar items (“Get Word File”, “Open Word File”) route to get() and open() methods, each of which call startActivityForResult() with the appropriate action string:

  @TargetApi(Build.VERSION_CODES.KITKAT)
  private void open() {
    Intent i=
      new Intent()
        .setType("text/plain")
        .setAction(Intent.ACTION_OPEN_DOCUMENT)
        .addCategory(Intent.CATEGORY_OPENABLE);

    startActivityForResult(i, REQUEST_OPEN);
  }

  private void get() {
    Intent i=
      new Intent()
        .setType("text/plain")
        .setAction(Intent.ACTION_GET_CONTENT)
        .addCategory(Intent.CATEGORY_OPENABLE);

    startActivityForResult(i, REQUEST_GET);
  }
(from Documents/Diceware/app/src/main/java/com/commonsware/android/diceware/PassphraseFragment.java)

In onActivityResult(), if we received a RESULT_OK response, we set up docObservable, where we:

createDurableContent(), along with obtainDurablePermissions(), makeLocalCopy(), and buildDocFileForUri(), does the same work that the DurablizerService did in the Documents/Durable sample app:

The biggest differences are that createDurableContent() saves the Uri of either the opened document (if we got permission) or the local copy in SharedPreferences, and it returns a DocumentFileCompat representing the external word list:

  private DocumentFileCompat createDurableContent(Intent result) throws IOException {
    Uri document=result.getData();
    ContentResolver resolver=getActivity().getContentResolver();
    boolean weHaveDurablePermission=obtainDurablePermission(resolver, document);

    if (!weHaveDurablePermission) {
      document=makeLocalCopy(getActivity(), resolver, document);
    }

    if (weHaveDurablePermission || document!=null) {
      prefs
        .edit()
        .putString(PREF_URI, document.toString())
        .commit();

      return(buildDocFileForUri(getActivity(), document));
    }

    throw new IllegalStateException("Could not get durable permission or make copy");
  }

  private static boolean obtainDurablePermission(ContentResolver resolver,
                                                 Uri document) {
    boolean weHaveDurablePermission=false;

    if (Build.VERSION.SDK_INT>=Build.VERSION_CODES.KITKAT) {
      int perms=Intent.FLAG_GRANT_READ_URI_PERMISSION
        | Intent.FLAG_GRANT_WRITE_URI_PERMISSION;

      try {
        resolver.takePersistableUriPermission(document, perms);

        for (UriPermission perm : resolver.getPersistedUriPermissions()) {
          if (perm.getUri().equals(document)) {
            weHaveDurablePermission=true;
          }
        }
      }
      catch (SecurityException e) {
        // OK, we were not offered any persistable permissions
      }
    }

    return(weHaveDurablePermission);
  }

  private static Uri makeLocalCopy(Context ctxt, ContentResolver resolver,
                                   Uri document)
    throws IOException {
    DocumentFileCompat docFile=buildDocFileForUri(ctxt, document);
    Uri result=null;

    if (docFile.getName()!=null) {
      String ext=
        MimeTypeMap.getSingleton().getExtensionFromMimeType(docFile.getType());

      if (ext!=null) {
        ext="."+ext;
      }

      File f=File.createTempFile("cw_", ext, ctxt.getFilesDir());

      docFile.copyTo(f);
      result=Uri.fromFile(f);
    }

    return(result);
  }

  private static DocumentFileCompat buildDocFileForUri(Context ctxt, Uri document) {
    DocumentFileCompat docFile;

    if (document.getScheme().equals(ContentResolver.SCHEME_CONTENT)) {
      docFile=DocumentFileCompat.fromSingleUri(ctxt, document);
    }
    else {
      docFile=DocumentFileCompat.fromFile(new File(document.getPath()));
    }

    return(docFile);
  }
(from Documents/Diceware/app/src/main/java/com/commonsware/android/diceware/PassphraseFragment.java)

onActivityResult() then calls docSub():

  private void docSub() {
    docSub=docObservable.subscribe(documentFile -> {
      docObservable=null;
      loadWords(true, true);
    });
  }
(from Documents/Diceware/app/src/main/java/com/commonsware/android/diceware/PassphraseFragment.java)

Here, we subscribe to docObservable, and when the work is complete, we set docObservable to null (indicating that we are done with it), then call loadWords(true, true) to reload the words from our new source and generate a new passphrase based on those words.

The net result is that tapping either of those action bar items brings up the appropriate system UI to pick a piece of content, after which we start using that content. And, since we are persisting the Uri to that external word list in SharedPreferences, we will continue using that word source for the foreseeable future.

Other Fiddly Bits

That “forseeable future” may not be all that long. If the user taps the “Reset” action bar overflow item, we clear() our SharedPreferences, then call loadWords(true, true) to read in the asset’s words plus generate a passphrase based that word list:

      case R.id.reset:
        prefs.edit().clear().apply();
        loadWords(true, true);
        return(true);
(from Documents/Diceware/app/src/main/java/com/commonsware/android/diceware/PassphraseFragment.java)

The action bar itself is populated from a menu resource, one that defines a submenu for the possible wordCount values:

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
  <item
    android:id="@+id/word_count"
    android:showAsAction="ifRoom"
    android:title="@string/menu_words">
    <menu>
      <group android:checkableBehavior="single">
        <item
          android:id="@+id/word_count_4"
          android:title="4" />
        <item
          android:id="@+id/word_count_5"
          android:title="5" />
        <item
          android:id="@+id/word_count_6"
          android:checked="true"
          android:title="6" />
        <item
          android:id="@+id/word_count_7"
          android:title="7" />
        <item
          android:id="@+id/word_count_8"
          android:title="8" />
        <item
          android:id="@+id/word_count_9"
          android:title="9" />
        <item
          android:id="@+id/word_count_10"
          android:title="10" />
      </group>
    </menu>
  </item>
  <item
    android:id="@+id/refresh"
    android:icon="@drawable/ic_cached_white_24dp"
    android:showAsAction="ifRoom"
    android:title="@string/menu_refresh" />
  <item
    android:id="@+id/get"
    android:showAsAction="never"
    android:title="@string/get" />
  <item
    android:id="@+id/open"
    android:enabled="false"
    android:showAsAction="never"
    android:title="@string/open" />
  <item
    android:id="@+id/reset"
    android:showAsAction="never"
    android:title="@string/menu_reset" />

</menu>
(from Documents/Diceware/app/src/main/res/menu/actions.xml)

We start with the word_count_6 item checked, and the wordCount field is also initialized to 6. If the user taps on any of those submenu items, we update the submenu to check the proper item, figure out the new wordCount (by cheating and parsing the menu title as an Integer), and if the user changed word count values we generate a fresh passphrase (without re-loading the word list):

      case R.id.word_count_4:
      case R.id.word_count_5:
      case R.id.word_count_6:
      case R.id.word_count_7:
      case R.id.word_count_8:
      case R.id.word_count_9:
      case R.id.word_count_10:
        item.setChecked(!item.isChecked());

        int temp=Integer.parseInt(item.getTitle().toString());

        if (temp!=wordCount) {
          wordCount=temp;
          loadWords(false, true);
        }

        return(true);
(from Documents/Diceware/app/src/main/java/com/commonsware/android/diceware/PassphraseFragment.java)

Since that wordCount value can change, we save it as part of the saved instance state Bundle in onSaveInstanceState(), and we pull that value back out of that Bundle in onCreateView().

However, we are not putting the generated passphrase in the saved instance state Bundle ourselves, even though clearly that is being generated at runtime. Here, Diceware takes advantage of a bit of a trick. On the layout resource, the TextView that shows the passphrase has android:freezesText="true":

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:padding="8dp">

  <android.support.v7.widget.CardView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="center"
    android:padding="8dp">

    <TextView
      android:id="@+id/passphrase"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:freezesText="true"
      android:textSize="20sp"
      android:typeface="monospace" />
  </android.support.v7.widget.CardView>

</FrameLayout>
(from Documents/Diceware/app/src/main/res/layout/activity_main.xml)

Ordinarily, the contents of a TextView are not part of the saved instance state Bundle, because Android assumes that those values are fixed (e.g., via android:text in the layout). In our case, we are generating the passphrase at runtime, and so android:freezesText="true" tells Android to hold onto our TextView content in the saved instance state Bundle automatically, the way that it does for EditText. Hence, with that one attribute, Android will take care of holding onto the passphrase across configuration changes for us.

Document Trees

ACTION_OPEN_DOCUMENT and ACTION_CREATE_DOCUMENT are sufficient for most apps. These roughly correspond to the “file open” and “new file” dialogs that you see in desktop operating systems.

However, there may be cases where you need the equivalent of a “choose folder” 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 “folder”, into which you can create all of those individual bits of content.

For that, the Storage Access Framework offers document trees… as of Android 5.0 (API Level 21). Android 4.4’s edition of the Storage Access Framework lacked this capability.

Getting a Tree

Instead of using ACTION_OPEN_DOCUMENT, you can use ACTION_OPEN_DOCUMENT_TREE. Once again, you will use startActivityForResult() to request access to the tree. In onActivityResult(), the result Intent has a Uri (getData()) that represents the tree. You should have full read/write access not only to this tree but to anything inside of it.

Another option, starting with Android 7.0, is “scoped directory access”. Here, you work with StorageManager to access the device’s StorageVolume list. All devices should have at least one StorageVolume, representing what we think of as external storage. Some devices may have more than that, representing mounted removable media. Given a StorageVolume, you can call createAccessIntent() to get an Intent that will ask the user permission for access to some portion of that volume, when called with startActivityForResult(). As with ACTION_OPEN_DOCUMENT_TREE, you get a Uri in onActivityResult() that you can then use to work with that tree of files.

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.

Getting a Tree: Example

The Documents/DocumentTree sample application demonstrates how to use ACTION_OPEN_DOCUMENT_TREE and StorageManager/StorageVolume to get a Uri pointing to a directory that you can work with.

The Objective: a Preference for Storage

The sample app’s UI is a PreferenceFragment, where we have two preferences: one to pick a document tree via ACTION_OPEN_DOCUMENT_TREE and one to pick a StorageVolume from among the available volumes. In theory, an app might include one of these for the user to pick an alternative default storage location for files, for example. However, since the StorageVolume APIs for choosing a storage volume are new to API Level 24, we will only enable that preference on compatible devices.

In each case, part of the work to get access to these locations involves startActivityForResult(), which is unusual for a preference and adds to the sample’s complexity.

What the User Sees

When the user first launches the app, the preference subtitles are “no value”, because the user has not chosen anything yet:

DocumentTree Demo, As Initially Launched
Figure 759: DocumentTree Demo, As Initially Launched

If the user taps the “Document Tree Root” preference, the UI for the Storage Access Framework appears, allowing the user to browse for a directory of interest:

DocumentTree Demo, Showing Internal Storage via SAF
Figure 760: DocumentTree Demo, Showing “Internal Storage” via SAF

If the user chooses a location, the preference is updated with the Uri of the selected document tree:

DocumentTree Demo, Showing Selected Document Tree Uri
Figure 761: DocumentTree Demo, Showing Selected Document Tree Uri

If the user taps the “Storage Volume” preference, a ListPreference dialog appears, showing the available storage volumes:

DocumentTree Demo, Showing Available Storage Volume(s)
Figure 762: DocumentTree Demo, Showing Available Storage Volume(s)

On some devices, there will only be one option (“Internal shared storage”, or what we developer call “external storage”). On other devices, if there is a piece of removable storage mounted, there will be more than one option.

If the user chooses a volume, a permission confirmation dialog may appear, to confirm that the user wants to grant you access to the “Documents” directory inside of that storage volume:

DocumentTree Demo, Requesting Permission
Figure 763: DocumentTree Demo, Requesting Permission

If the user grants permission, once again the preference’s subtitle will reflect the Uri of the chosen location:

DocumentTree Demo, Showing Selected Storage Volume Directory
Figure 764: DocumentTree Demo, Showing Selected Storage Volume Directory

On Android 5.0-6.0 devices, the app will run, but the storage volume preference is disabled:

DocumentTree Demo, Running on Android 6.0
Figure 765: DocumentTree Demo, Running on Android 6.0

The Document Tree

Of the two options, ACTION_OPEN_DOCUMENT_TREE is the most straight-forward to implement: call startActivityForResult() and get your Uri in onActivityResult().

But, since preferences are not set up to handle startActivityForResult() or receive data via onActivityResult(), we have a little bit of work to do.

The Preference XML

The app has a res/raw/settings.xml file containing our preferences:

<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
  <Preference
    android:key="documentTree"
    android:title="@string/pref_doc_tree" />
  <ListPreference
    android:dialogTitle="@string/dlg_storage_volume"
    android:enabled="@bool/is_nougat"
    android:key="storageVolume"
    android:title="@string/pref_storage_volume" />
</PreferenceScreen>
(from Documents/DocumentTree/app/src/main/res/xml/settings.xml)

The first one is our “Document Tree Root” preference… and it is literally a Preference. This is not used all that frequently, since it cannot actually collect any preference data. In cases like this one, where we really want to handle this more like the user tapped on a generic ListView row, it is a reasonable choice.

We will explore the ListPreference for the “Storage Volume” option later in this section.

Populating the Preference

The UI is a PreferenceFragment subclass named SettingsFragment. In onCreate(), we call addPreferencesFromResource() to inflate that preference XML and populate the fragment:

  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    addPreferencesFromResource(R.xml.settings);

    prefDocTree=findPreference(PREF_DOC_TREE);
    prefs=prefDocTree
      .getSharedPreferences();
    prefs.registerOnSharedPreferenceChangeListener(this);
    onSharedPreferenceChanged(prefs, PREF_DOC_TREE);
    docTreeHelper=new DocumentHelper(this,
      prefDocTree);

    prefVolumes=(ListPreference)findPreference(PREF_VOLUMES);

    if (prefVolumes.isEnabled()) {
      populateVolumes();
      onSharedPreferenceChanged(prefs, PREF_STORAGE_URI);
      volumeHelper=
        new VolumeHelper(this, prefVolumes,
          PREF_STORAGE_URI, Environment.DIRECTORY_DOCUMENTS);
    }
  }
(from Documents/DocumentTree/app/src/main/java/com/commonsware/android/documenttree/SettingsFragment.java)

We then do a few things to set up our “Document Tree Root” preference:

  @Override
  public void onSharedPreferenceChanged(SharedPreferences prefs,
                                        String key) {
    if (PREF_DOC_TREE.equals(key)) {
      prefDocTree.setSummary(prefs.getString(key, "<no value>"));
    }
    else if (PREF_STORAGE_URI.equals(key)) {
      prefVolumes
        .setSummary(prefs
          .getString(key, "<no value>").replaceAll("%", "%%"));
    }
  }
(from Documents/DocumentTree/app/src/main/java/com/commonsware/android/documenttree/SettingsFragment.java)

We will cover the remainder of this code, pertaining to the other preference, later.

Choosing a Tree

We need some common code between the document-root and the storage-volume options:

The TreeUriPreferenceHelper abstract class, along with its DocumentHelper and VolumeHelper subclasses, implement this common code.

A TreeUriPreferenceHelper subclass’ constructor needs to be passed the Preference that we are “helping”, along with some implementation of the Host interface:

  public interface Host {
    void startActivityForHelper(Intent intent,
                                TreeUriPreferenceHelper helper);
  }
(from Documents/DocumentTree/app/src/main/java/com/commonsware/android/documenttree/TreeUriPreferenceHelper.java)

DocumentHelper simply collects those values, passes them to TreeUriPreferenceHelper, and registers itself to be called when the user clicks on the Preference:

package com.commonsware.android.documenttree;

import android.content.Intent;
import android.preference.Preference;

public class DocumentHelper extends TreeUriPreferenceHelper
  implements Preference.OnPreferenceClickListener {
  public DocumentHelper(Host host, Preference pref) {
    super(host, pref);
    pref.setOnPreferenceClickListener(this);
  }

  @Override
  protected String getUriKey() {
    return (pref.getKey());
  }

  @Override
  public boolean onPreferenceClick(Preference preference) {
    Intent i=new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);

    host.startActivityForHelper(i, this);

    return(true);
  }
}
(from Documents/DocumentTree/app/src/main/java/com/commonsware/android/documenttree/DocumentHelper.java)

TreeUriPreferenceHelper, in turn, just holds onto the Host and Preference in host and pref fields, respectively.

When the user clicks on the Preference, the onPreferenceClick() method of the DocumentHelper is called. There, we create an ACTION_OPEN_DOCUMENT_TREE Intent and call startActivityForHelper() on the host.

Our Host, in this case, is the SettingsFragment, so it has an implementation of startActivityForHelper():

  @Override
  public void startActivityForHelper(Intent intent,
                                     TreeUriPreferenceHelper helper) {
    if (helper==docTreeHelper) {
      startActivityForResult(intent, REQUEST_DOC_TREE);
    }
    else if (helper==volumeHelper) {
      startActivityForResult(intent, REQUEST_STORAGE_VOLUME);
    }
  }
(from Documents/DocumentTree/app/src/main/java/com/commonsware/android/documenttree/SettingsFragment.java)

It just sees which TreeUriPreferenceHelper we are working with, then calls startActivityForResult() with an appropriate request code (e.g., REQUEST_DOC_TREE).

Eventually, SettingsFragment should be called with onActivityResult(). If the result is RESULT_OK, we forward the result along to the TreeUriPreferenceHelper, based on the request code:

  @Override
  public void onActivityResult(int requestCode, int resultCode,
                               Intent data) {
    if (resultCode==Activity.RESULT_OK) {
      if (requestCode==REQUEST_DOC_TREE) {
        docTreeHelper.onActivityResult(data);
      }
      else if (requestCode==REQUEST_STORAGE_VOLUME) {
        volumeHelper.onActivityResult(data);
      }
    }
  }
(from Documents/DocumentTree/app/src/main/java/com/commonsware/android/documenttree/SettingsFragment.java)

TreeUriPreferenceHelper has the common implementation of onActivityResult(), where we call takePersistableUriPermission() (asking for read/write access) and put the Uri into the SharedPreferences under some key:

  public void onActivityResult(Intent data) {
    Uri docTree=data.getData();
    ContentResolver cr=pref.getContext().getContentResolver();
    int perms=Intent.FLAG_GRANT_READ_URI_PERMISSION
      | Intent.FLAG_GRANT_WRITE_URI_PERMISSION;

    cr.takePersistableUriPermission(docTree, perms);

    pref
      .getSharedPreferences()
      .edit()
      .putString(getUriKey(), docTree.toString())
      .apply();
  }
(from Documents/DocumentTree/app/src/main/java/com/commonsware/android/documenttree/TreeUriPreferenceHelper.java)

In the case of the document-root Preference, that key is the key from the Preference itself (getKey()). Saving the value not only persists it, but it also triggers the SettingsFragment to be notified about the new value, causing SettingsFragment to update the Preference summary… which is why we see the Uri show up on the screen right after selecting it.

The Storage Volume

The StorageVolume scenario is a bit more complicated, in that we have to provide the UI for choosing a volume — this is not provided by Android. That, plus some interesting challenges in the StorageVolume implementation, add to our level of effort.

The Preference XML

The settings.xml file has a ListPreference that will serve as the UI for selecting a StorageVolume:

  <ListPreference
    android:dialogTitle="@string/dlg_storage_volume"
    android:enabled="@bool/is_nougat"
    android:key="storageVolume"
(from Documents/DocumentTree/app/src/main/res/xml/settings.xml)

Since the roster of possible volumes is dynamic, we cannot provide our ListPreference contents via string-array resources, but instead will need to do so from Java code.

Note that this preference is enabled based upon an is_nougat bool resource. That is set to true for API Level 24+ (in res/values-v24/bools.xml), false otherwise (in res/values/bools.xml).

Populating the Preference

Some of the work from onCreate() of SettingsFragment is for setting up this ListPreference:

    prefVolumes=(ListPreference)findPreference(PREF_VOLUMES);

    if (prefVolumes.isEnabled()) {
      populateVolumes();
      onSharedPreferenceChanged(prefs, PREF_STORAGE_URI);
      volumeHelper=
        new VolumeHelper(this, prefVolumes,
          PREF_STORAGE_URI, Environment.DIRECTORY_DOCUMENTS);
    }
(from Documents/DocumentTree/app/src/main/java/com/commonsware/android/documenttree/SettingsFragment.java)

We store the ListPreference in a prefVolumes field, before calling a private populateVolumes() method to fill in the list contents. We also trigger updating its summary via a manual call to onSharedPreferenceChanged(), plus wrap the ListPreference in a VolumeHelper that we will explore in detail shortly. And all of this is wrapped in a check to see if the preference is enabled; if it is not, we skip this work, as it is unnecessary.

populateVolumes() is responsible for providing the entries and values for the ListPreference, based on the available volumes:

  @TargetApi(Build.VERSION_CODES.N)
  private void populateVolumes() {
    StorageManager storage=
      (StorageManager)getActivity()
        .getSystemService(Context.STORAGE_SERVICE);
    List<StorageVolume> volumes=storage.getStorageVolumes();

    Collections.sort(volumes, new Comparator<StorageVolume>() {
      @Override
      public int compare(StorageVolume lhs,
                         StorageVolume rhs) {
        return(lhs.getDescription(getActivity())
          .compareTo(rhs.getDescription(getActivity())));
      }
    });

    String[] displayNames=new String[volumes.size()];
    String[] uuids=new String[volumes.size()];

    for (int i=0;i<volumes.size();i++) {
      displayNames[i]=volumes.get(i).getDescription(getActivity());
      uuids[i]=volumes.get(i).getUuid();

      if (uuids[i]==null) {
        uuids[i]=STORAGE_FAKE_UUID;
      }
    }

    prefVolumes.setEntries(displayNames);
    prefVolumes.setEntryValues(uuids);
  }
(from Documents/DocumentTree/app/src/main/java/com/commonsware/android/documenttree/SettingsFragment.java)

We start off by getting a StorageManager system service. Here, we are using the newer version of getSystemService(), introduced in API Level 21, where we can pass in the Java class of the system service that we want (StorageManager.class). This allows Android to return an instance of the actual class, avoiding a cast.

Then, we call getStorageVolumes() on the StorageManager, to get the roster of available StorageVolume objects.

Since those StorageVolume objects might arrive in any order, we sort() them by their description, which is a human-readable label describing what the volume is. For example, for removable storage, it might be a combination of the manufacturer of the drive or card, plus the stated capacity of the drive or card.

Since ListPreference wants two String arrays for the entries and values, we set those up, filling them in from the description and UUIDs of the volumes. Each volume is supposed to have a UUID, but that is not guaranteed — in particular, the StorageVolume for external storage returns null for getUuid(). Since ListPreference really does not like null values, we substitute in a non-UUID string (STORAGE_FAKE_UUID, defined to be "fake") to identify it. We then give those two string arrays to the ListPreference.

Choosing a Volume

VolumeHelper, like DocumentHelper, is designed to help bridge between the preference system, the hosting fragment, and the Android APIs for getting a document tree Uri.

VolumeHelper takes two additional constructor parameters, beyond the Host and Preference:

The constructor holds onto those additional parameters in fields, then registers itself to respond to when the ListPreference value changes, because the user selected a different StorageVolume in the list:

package com.commonsware.android.documenttree;

import android.content.Intent;
import android.os.storage.StorageManager;
import android.os.storage.StorageVolume;
import android.preference.ListPreference;
import android.preference.Preference;
import java.util.List;

public class VolumeHelper extends TreeUriPreferenceHelper
  implements Preference.OnPreferenceChangeListener {
  private final String uriKey;
  private final String dirName;

  public VolumeHelper(Host host, ListPreference pref, String uriKey,
                      String dirName) {
    super(host, pref);

    this.uriKey=uriKey;
    this.dirName=dirName;
    pref.setOnPreferenceChangeListener(this);
  }

  @Override
  protected String getUriKey() {
    return(uriKey);
  }

  @Override
  public boolean onPreferenceChange(Preference pref,
                                    Object o) {
    StorageManager storage=
      pref.getContext().getSystemService(StorageManager.class);
    List<StorageVolume> volumes=storage.getStorageVolumes();
    String uuid=o.toString();

    for (StorageVolume volume : volumes) {
      if ((volume.getUuid()==null &&
        uuid.equals(SettingsFragment.STORAGE_FAKE_UUID)) ||
        (uuid.equals(volume.getUuid()))) {
        Intent i=volume.createAccessIntent(dirName);

        host.startActivityForHelper(i, this);
        break;
      }
    }

    return(true);
  }
}
(from Documents/DocumentTree/app/src/main/java/com/commonsware/android/documenttree/VolumeHelper.java)

When the user eventually does change the selection in the ListPreference and onPreferenceChange() is called, we get a fresh StorageManager. Unfortunately, StorageManager does not have any sort of lookup API to get a StorageVolume by UUID, so we have to iterate over the currently-available volumes and find a match, taking into account our fake UUID for the null-UUID case.

When we find a match, we call createAccessIntent() on the StorageVolume, passing in the directory name. That Intent is then given to the Host via startActivityForHelper(). That will trigger the same process as was used for DocumentHelper, eventually resulting in the Uri being saved to the SharedPreferences under the supplied key.

Potential Issues

It is unclear if more than one StorageVolume could have a null UUID. If it can, the approach of using a fake value in lieu of null will not work. Of course, if more than one StorageVolume could have a null UUID, we will have no means of identifying which StorageVolume the null UUID refers to, making long-term identification of StorageVolume objects difficult.

Scoped Directory Access Bug

The scoped directory access feature — using StorageVolume to request access to standard directories — works fairly well on Android 7.0+… with one UX flaw, tied to how the user grants you that access.

The flow of the permission dialogs resembles that of Android 6.0’s runtime permissions:

One problem is that we have no good way of knowing that the user has previously denied our request, let alone checked the “Don’t ask again” checkbox. With Android 6.0’s runtime permissions, we have checkSelfPermission() and shouldShowPermissionRequestRationale() for those things. We have no equivalents for scoped directory access.

However, the bigger problem is that once the user checks “Don’t ask again” and denies access, the user has no further recourse. With runtime permissions, the user can always go into the Permissions area of an app’s page in Settings and manually grant permissions. There is no equivalent of this for the scoped directory access API.

On Android 7.1, the user can use “Clear Data” to reset these dialogs, causing future dialogs to appear again even if “Don’t ask again” had been checked. Of course, “Clear Data” has somewhat broader impact than this, and the user might not appreciate wiping out all the app’s local data.

Worse, on Android 7.0, not only does “Clear Data” not fix this, but a full uninstall of the app does not fix this. Nothing short of a factory reset will allow the app to ask the user for permission and the user have an opportunity again to grant permission.

Admittedly, this is an edge case, but it is one that you should keep in mind if you are using createAccessIntent() and the scoped directory access API. Keep an eye on this issue to try to get some resolution to how the user is supposed to manually revert the “Don’t ask again” status.

Android 8.0 Changes

Android 8.0 introduced some new options for consuming documents and document trees via the Storage Access Framework.

Document Tree Traversal

The DocumentsContract client interface to the Storage Access Framework now has a findDocumentPath() method. Many developers will think that this means that they can get a filesystem path for a document Uri from the Framework.

That is not what this method does.

Instead, findDocumentPath() returns a list of document IDs representing the hierarchy of document trees leading to the specific document being requested. In other words, if the document is inside of a goo tree, which is inside of a bar tree, which is inside of a foo tree, and foo is the top of a provider’s hierarchy, findDocumentPath() would return a List of document IDs representing foo, bar, goo, and the requested document.

Presumably, the idea is that this would be used by client UIs to help build a way to traverse the relevant set of document trees for a particular document.

For this to work, the corresponding DocumentsProvider needs to implement its own findDocumentPath() method.

Web Links for Documents

DocumentsContract also has a createWebLinkIntent() method, with a corresponding implementation on DocumentsProvider. This is very poorly documented, but apparently the idea is that for some cloud document providers, you can get a URL to the document, given the Storage Access Framework Uri for the document. Presumably, this is a publicly-visible document, and the URL could be sent to other parties (e.g., via email) for them to see the document.

Android itself does not ship with a cloud document provider. Google Play devices may ship with Google Drive, and Drive might support this feature.

Document Settings Activity

If you implement a DocumentsProvider, you have the option of including FLAG_SUPPORTS_SETTINGS in the details that you return for queryChildDocuments(). If you do that, and you have an activity that supports ACTION_DOCUMENT_SETTINGS, the user may be presented with an option to visit that activity. The activity should be given the Uri of the particular document that the user wishes to manage. A provider might use this for offering management of metadata for the document: tags, access rights, etc.