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.
This chapter assumes that you have read
the chapter on ContentProvider
patterns or have equivalent
experience with consuming streams published by a ContentProvider
.
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:
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.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).
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).
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.
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:
ACTION_OPEN_DOCUMENT
as the actionCATEGORY_OPENABLE
as the categoryThen, 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);
}
}
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());
}
}
}
}
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
}
If we get results back, we try to read out these two values and record them to the transcript as well. Note, though, that:
isNull()
on the Cursor
to see if we actually
have a SIZE
value before trying to get it as an integerThe 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:
Figure 753: Storage Access Framework Picker, Showing Images
When the user taps on an image, the results wind up in our transcript UI:
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.
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:
Uri
for is to wrap it in an
ACTION_VIEW
Intent
and start an activity on it, you could skip
CATEGORY_OPENABLE
and perhaps offer more choices to your userCATEGORY_OPENABLE
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.
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:
ACTION_CREATE_DOCUMENT
CATEGORY_OPENABLE
EXTRA_TITLE
, containing your desired filenameThen, invoke startActivityForResult()
on that Intent
, and use the Uri
supplied in the result Intent
delivered to onActivityResult()
.
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.
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 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:
canRead()
and canWrite()
)getName()
) and length (length()
) of the documentgetType()
)delete()
)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.
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:
OpenableColumns
OpenableColumns
ContentResolver
and getType()
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:
openInputStream()
, openOutputStream()
)copyTo()
)copyFrom()
, copyFromAsset()
)We will see the use of DocumentFileCompat
in the next couple of sample apps.
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:
Uri
, do not assume that the Uri
will still be validUri
values that you get from
ACTION_OPEN_DOCUMENT
or ACTION_CREATE_DOCUMENT
, not ones that you get
by other means (e.g., ACTION_GET_CONTENT
)releasePersistableUriPermission()
later on, and there is nothing stopping the storage provider from
revoking those permissions itself at some time in the futureIn 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:
ACTION_OPEN_DOCUMENT
or getting content via ACTION_GET_CONTENT
IntentService
(DurablizerService
)
to either obtain persistable permissions to the
content or to make a local copy, delivering the results asynchronously via
an event busDocumentFileCompat
instead of direct ContentResolver
queries to
get the content metadataFor 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'
}
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();
}
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>
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));
}
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);
}
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));
}
}
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:
DocumentFile
to the UI layer via
a ContentReadyEvent
@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));
}
}
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);
}
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);
}
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);
}
This handles both likely scenarios:
content:
Uri
, and so our DocumentFileCompat
needs to be created
using the fromSingleUri()
factory methodfile:
Uri
, and so our DocumentFileCompat
needs to be created
using the fromFile()
factory methodBack 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;
}
}
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()));
}
For cases where we are granted persistable permissions, the output
will show a content:
Uri
, as we can continue to use the original
content:
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:
Figure 756: Durable Document Demo, Showing Content Result
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.
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.
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.
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:
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):
Figure 758: Diceware App, Showing Word Count Submenu
There are three options in the action bar overflow:
ACTION_GET_CONTENT
to allow you to pick an
alternative word list fileACTION_OPEN_DOCUMENT
to allow you to do the sameThe Diceware
app consists of a single PassphraseFragment
, loaded using a FragmentTransaction
by the MainActivity
.
PassphraseFragment
uses two RxJava Observable
objects:
wordsObservable
)docObservable
)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);
}
}
(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);
});
}
}
We only need to set up wordsObservable
if it does not already exist or
forceReload
is true
. In either of those cases, we:
defer()
to set up an Observable
that starts by loading the default
SharedPreferences
for our app, as that is where we will record the Uri
of any external word list that we are supposed to be usingsubscribeOn()
to move all this I/O to a background threadmap()
to extract the String
preference value, keyed by PREF_URI
,
that is the Uri
for our external word list, returning the empty string if
there is no such Uri
(note: we cannot return null
because RxJava does not
like that)map()
to create an InputStream
either on that external word
list or the one in assets/
, then read in the words from that stream
via readWords()
cache()
to get RxJava to hold onto the results of all that work, so long
as we have the same wordsObservable
objectobserveOn()
to arrange to observe the results on the main application
threadreadWords()
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);
}
loadWords()
then calls unsubWords()
. This will dispose()
any existing
subscription to wordsObservable
:
private void unsubWords() {
if (wordsSub!=null && !wordsSub.isDisposed()) {
wordsSub.dispose();
}
}
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());
}
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.
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);
}
In onActivityResult()
, if we received a RESULT_OK
response, we set
up docObservable
, where we:
defer()
and just()
to “observe” the results of calling createDurableContent()
,
supplying it the Intent
that is the result of the startActivityForResult()
callsubscribeOn()
to move that work to a background threadcache()
to cache the results for as long as we have docObservable
observeOn()
to observe the results on the main application thread.createDurableContent()
, along with obtainDurablePermissions()
,
makeLocalCopy()
, and buildDocFileForUri()
,
does the same work that the DurablizerService
did in the
Documents/Durable
sample app:
Uri
permissions, if we can have themThe 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);
}
onActivityResult()
then calls docSub()
:
private void docSub() {
docSub=docObservable.subscribe(documentFile -> {
docObservable=null;
loadWords(true, true);
});
}
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.
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);
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>
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);
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>
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.
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.
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.
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:
listFiles()
to get the immediate children of the root of this
tree, getting back an array of DocumentFile
objects representing
those childrenisDirectory()
to confirm that you do indeed have a tree
(or, call it on a child to see if that child represents a sub-tree)isFile()
returns
true
), use getUri()
to get the Uri
for this child, so you can
read its contents using a ContentResolver
and openInputStream()
createDirectory()
or createFile()
to add new content as an
immediate child of this tree, getting a DocumentFile
as a resultcreateFile()
scenario, call getUri()
on the DocumentFile
to get a Uri
that you can use for writing out the content using
ContentResolver
and openOutputStream()
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.
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 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.
When the user first launches the app, the preference subtitles are “no value”, because the user has not chosen anything yet:
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:
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:
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:
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:
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:
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:
Figure 765: DocumentTree Demo, Running on Android 6.0
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 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>
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.
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);
}
}
We then do a few things to set up our “Document Tree Root” preference:
findPreference()
to get that Preference
object, storing
it in a prefDocTree
field.Preference
for the SharedPreferences
that are being
used, holding onto that in a field named prefs
.OnSharedPreferenceChangeListener
for the SharedPreferences
,
then immediately call onSharedPreferenceChanged()
. That, in turn,
fills in the summary of the Preference
with the current Uri
, if we
have one: @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("%", "%%"));
}
}
Preference
in a DocumentHelper
object,
which we will look at shortly.We will cover the remainder of this code, pertaining to the other preference, later.
We need some common code between the document-root and the storage-volume options:
Preference
and a hosting activity or fragment
that can do the startActivityForResult()
and onActivityResult()
worktakePersistableUriPermission()
SharedPreferences
with the Uri
that we receiveThe 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);
}
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);
}
}
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);
}
}
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);
}
}
}
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();
}
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 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 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"
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
).
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);
}
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);
}
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
.
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
:
SharedPreference
under which the Uri
will be
stored. The ListPreference
will store the UUID of the storage volume
under its key, but once we get the Uri
, we need to save it to the
SharedPreferences
as well, for later use.createAccessIntent()
accepts any of the standard Environment
directories — in this case,
we are using DIRECTORY_DOWNLOADS
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);
}
}
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.
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.
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 introduced some new options for consuming documents and document trees via the Storage Access Framework.
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.
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.
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.