Mark L. Murphy's Blog, page 18

February 8, 2020

GDSA for App Distribution: Promising, Yet Problems

Making the news
this week is the Global Developer Service Alliance (GDSA),
an initiative of Oppo, Vivo, Xiaomi, and (maybe) Huawei. It aims to provide
a central site for developers to be able to submit apps to all of the partners���
app distribution channels. And, with an English-language site option, an apparent
objective is to help encourage developers from beyond China to publish in those
app distribution channels.



This sort of ���write once, submit anywhere��� approach for app distribution is certainly
welcome. I remember delivering an ah hoc presentation on the topic at droidcon
UK nearly a decade ago. IMHO, a more powerful solution would be to have a standardized
app distribution API, perhaps reminiscent of Google���s for the Play Store. Perhaps
that will be added in the future. Still, a central site is better than nothing.



That being said, the site in its current state��� well, let���s just say that it
needs work. Perhaps it was announced before it was ready ���
the Reuters article
calls it a ���prototype���. Plus, for this sort of thing to succeed, beyond the actual
site technology, you need the developer advocates who can help with the site content,
developer support, and overall evangelism. Right now, I do not see any sign of that.



GSDA has a tough road ahead of it, but it is worth keeping an eye on, particularly
if they expand to include other app distribution channels beyond the manufacturer-specific
ones currently in the alliance.

 •  0 comments  •  flag
Share on Twitter
Published on February 08, 2020 06:29

February 1, 2020

Start Before You Finish

Let���s take ���a trip down Memory Lane��� and talk a bit about API design, using
as an example a common thing in Android: starting and finishing activities.



The Scenario

Particularly in activity-centric UI architectures, there may be cases where
we want to show an activity, then switch to a different activity, where the
first activity is no longer needed in the back stack. A common example of this
is the splash screen:




graph LR
Launcher --> Splash
Splash --> Main
Main -->|BACK pressed| Launcher


If the user is in Main and presses BACK, we do not want the user to wind up in
Splash ��� instead, they should exit the app and return to the launcher (or
wherever they started from).



So, in Splash, we could call startActivity() to start Main, plus call finish()
to remove Splash from the task.



However, do we:




Call startActivity(), then finish()?
Call finish(), then startActivity()?
Do either of those, as the order of calls does not matter?


The Answer

Vitaliy Khudenko decided to research this topic,
and he concluded that the right answer is to call startActivity(), then
finish().



In the scenario that I just described, where Splash is the only
one in the task, if we call finish() before startActivity(), Android will
add FLAG_ACTIVITY_NEW_TASK automatically to the Intent used in the startActivity()
call. If we call startActivity() first, Android does not add this flag.
And, it is possible that this flag may have an impact on the behavior of the
activity being started.



The Knowledge Gap

This behavior is undocumented, as far as Vitaliy and I can tell. Vitaliy filed
an issue to try to get
this covered.



From an API design standpoint, though, could we do better?



Clearly, we need to be able to call startActivity()
without always finishing. Similarly, we will have cases where we need to call finish()
without starting another activity. As a result, we need the two existing primitive methods.



However, in cases where you have a pair of simple calls, where there is a preferred
way for them to be used in combination, consider adding something to the API that
helps developers ���do the right thing���. In this case, that could be:





A flag on startActivity() to indicate if the current activity should
then finish




A startActivityAndFinish() method that does both actions in the typical order




Have startActivity() return an ActivityStartContinuation, on which there
is a thenFinish() function, so we can write startActivity(...).thenFinish()




And so on





Now, the Android SDK is vast, and adding these sorts of things makes it even
more vast (vaster?). In that case, going the documentation route may be preferable.
And of course there is nothing stopping you from adding convenience methods and
documenting the reason for them.



But, ideally, your SDK calls out scenarios like this and helps developers easily do what
most developers would want, with the flexibility of supporting other, less-common
options.





Many thanks to Vitaliy for pointing out the problem!

 •  0 comments  •  flag
Share on Twitter
Published on February 01, 2020 06:15

January 20, 2020

���Elements of Android Jetpack��� Version 0.7 Released

Subscribers now have
access to Version 0.7 of Elements of Android Jetpack,
in PDF, EPUB, and MOBI/Kindle formats. Just log into
your Warescription page to download it,
or set up an account and subscribe!



This update adds five new chapters:





Signing Your App




Shrinking Your App




Using the AVD Manager and the Emulator




Using the SDK Manager




Configuring Your Project





And, as usual, it fixes lots of bugs.

 •  0 comments  •  flag
Share on Twitter
Published on January 20, 2020 05:43

January 13, 2020

���Elements of Android Room��� Version 0.1 Released

Subscribers now have
access to Version 0.1 of Elements of Android Room,
in PDF, EPUB, and MOBI/Kindle formats. Just log into
your Warescription page to download it,
or set up an account and subscribe!





I cover a fair amount of material in Android���s Architecture Components.
However, that is a book on first-generation Android development techniques, using
Java and the Android Support Library edition of the Architecture Components. And the biggest
single topic in Android���s Architecture Components is on
Room, Google���s
reactive abstraction layer over SQLite.



Elements of Android Room is all about the updates:





Updating the material to cover second-generation Android, including Kotlin
and the Jetpack/AndroidX version of the Architecture Components




Updating the material to reflect newer Room features, from coroutines/Flow
to built-in full-text indexing support, probably doubling the total Room
coverage from what I had in Android���s Architecture Components




Updating the references to external tools and libraries, notably for integrating
Room with SQLCipher for Android without the need for
my SafeRoom library





This is Version 0.1, so it is a work in progress. I have all of the basics
in there:





Entity, DAO, and database setup




Coroutines, RxJava, and LiveData




Migrations




And so on





More advanced topics will be added in the coming months.



As always, with Version 0.1 of the book, there may be production problems. If
you run into issues with your copy of the book, let me know!

 •  0 comments  •  flag
Share on Twitter
Published on January 13, 2020 05:36

January 11, 2020

Scoped Storage Stories: The Diabolical Details of Downloads

Android 10 is greatly restricting access to external storage
via filesystem APIs. Instead, we need to use other APIs to work with content.
This is the eleventh and final(?) post in a series where we will explore how to work with those
alternatives, now looking at MediaStore options.





Another wrinkle introduced by Android 10 was MediaStore.Downloads. This is advertised
as being another MediaStore collection that we can use, akin to the ones for
video, pictures, etc.



The basic usage of MediaStore.Downloads indeed does match what we use for
other MediaStore collections. The Download Wrangler sample app
is a bit of a mash-up of some of the previous samples, with a DownloadRepository class
that offers:





A download() function to download a URL to a requested filename in Downloads, and




A listTitles() function to query Downloads and return the names of
all the items found in there





These have both ���Q��� and ���legacy��� variants, where the ���Q��� edition uses
MediaStore.Downloads and older devices use
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).



Those functions are tied to three toolbar buttons in the UI:





A ���refresh��� button that calls listTitles() and updates a RecyclerView with the titles




A ���download��� button that downloads a PDF of an appendix from Elements of Kotlin Coroutines
that I used in an Android Summit workshop




A ���all��� button that appears on Android 10 devices and allows you to ���upgrade���
your permissions with READ_EXTERNAL_STORAGE (on Android 9 and older, that
permission is requested when you first run the app)





The behavior on legacy devices is what you would expect:





The list shows the contents of the Downloads/ directory




You need READ_EXTERNAL_STORAGE to read the contents of that directory and
WRITE_EXTERNAL_STORAGE to save content to that directory





On Android 10, what we would expect is:





Without any permissions, the list contains titles of content that you downloaded by means of MediaStore.Downloads




With READ_EXTERNAL_STORAGE, the list contains all content in MediaStore.Downloads, including
those from other apps




Without any permissions, you can save content to MediaStore.Downloads





The first and the last items in that list do occur. The second does not.
You cannot access other apps��� downloads via MediaStore.Downloads, even with READ_EXTERNAL_STORAGE permission.
For that matter, WRITE_EXTERNAL_STORAGE does not help.



You can see this by running the sample app. It has two product flavors, cunningly
named one and two. Run one and download the PDF, then run two and use the ���all���
button to request READ_EXTERNAL_STORAGE. You will not see the PDF downloaded by
one or anything else that might reside in Downloads/.



This is covered in the documentation:




In particular, if your app wants to access a file within the MediaStore.Downloads collection that your app didn���t create, you must use the Storage Access Framework.




For apps that are merely downloading content to MediaStore.Downloads, or accessing
their own downloaded content, this is not a problem. It���s just something to bear
in mind: Android 10���s MediaStore enhancements are not the same across all
collections.





That should wrap up this blog post series, though I may write more on this subject
in the future if interesting edge cases come to my attention.



The entire series includes:




The basics of using the Storage Access Framework
Getting durable access to the selected content
Working with DocumentFile for individual documents
Working with document trees
Working with DocumentsContract
Problems with the SAF API
A specific problem with listFiles() on DocumentFile
Storing content using MediaStore
Reading content from the MediaStore
Modifying MediaStore content from other apps
Limitations of MediaStore.Downloads
 •  0 comments  •  flag
Share on Twitter
Published on January 11, 2020 11:56

January 5, 2020

Scoped Storage Stories: Modifying the Content of Other Apps

Android 10 is greatly restricting access to external storage
via filesystem APIs. Instead, we need to use other APIs to work with content.
This is the tenth post in a series where we will explore how to work with those
alternatives, now looking at MediaStore options.





Earlier, we saw that you can store stuff via MediaStore
on Android 10 without WRITE_EXTERNAL_STORAGE as a permission. And, we saw
that you can read your own MediaStore stuff without a permission, but
you need READ_EXTERNAL_STORAGE to read other apps��� stuff
in the MediaStore. The remaining piece is: how do we modify other apps��� stuff
in the MediaStore?



You might think that WRITE_EXTERNAL_STORAGE is the solution. After all, if READ_EXTERNAL_STORAGE
lets you read other apps��� stuff, WRITE_EXTERNAL_STORAGE should let you
modify other apps��� stuff. Right?



(if you have not read much of my writing, when I use that sort of ���leading argument���
paragraph structure, it is almost always creative foreshadowing of a reversal)



(we return you now to your regularly-scheduled blog post, already in progress���)



In truth, WRITE_EXTERNAL_STORAGE does not help here. WRITE_EXTERNAL_STORAGE grants
blanket access, and one of the objectives of Android 10���s scoped storage is to get
rid of that sort of blanket access.



Instead, Android 10 introduces the concept of a RecoverableSecurityException.
If we catch one of these, the exception allows us to request fine-grained access
from the user, and we can use that to proceed.



To illustrate this, let���s look at Google���s storage-samples repo
and the MediaStore sample inside of it.



This module has a MainActivityViewModel that, among other things, offers a performDeleteImage()
function. Basically, given a Uri, it will attempt to delete the item from the
MediaStore. However, frequently the item was not created by the app, but instead
was found by querying the MediaStore. As a result, deletion often fails��� but
we can recover from it.



private suspend fun performDeleteImage(image: MediaStoreImage) {
withContext(Dispatchers.IO) {
try {
/**
* In [Build.VERSION_CODES.Q] and above, it isn't possible to modify
* or delete items in MediaStore directly, and explicit permission
* must usually be obtained to do this.
*
* The way it works is the OS will throw a [RecoverableSecurityException],
* which we can catch here. Inside there's an [IntentSender] which the
* activity can use to prompt the user to grant permission to the item
* so it can be either updated or deleted.
*/
getApplication<Application>().contentResolver.delete(
image.contentUri,
"${MediaStore.Images.Media._ID} = ?",
arrayOf(image.id.toString())
)
} catch (securityException: SecurityException) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val recoverableSecurityException =
securityException as? RecoverableSecurityException
?: throw securityException

// Signal to the Activity that it needs to request permission and
// try the delete again if it succeeds.
pendingDeleteImage = image
_permissionNeededForDelete.postValue(
recoverableSecurityException.userAction.actionIntent.intentSender
)
} else {
throw securityException
}
}
}
}


This function tries to delete() the content using a ContentResolver.
If delete() throws an exception, we can see if that exception is
a RecoverableSecurityException. If it is, rather than treat the exception
normally, we use userAction.actionIntent.intentSender to get an IntentSender
associated with the exception. IntentSender is an uncommon class, but you get
one from a PendingIntent via getIntentSender(). Like a PendingIntent,
an IntentSender has the ability to send an Intent, where the holder of the
IntentSender does not know what the action is (start an activity? send a broadcast?
start a service?) or who the recipient is.



MainActivityViewModel then posts that IntentSender to a LiveData, which is
observed by MainActivity:



viewModel.permissionNeededForDelete.observe(this, Observer { intentSender ->
intentSender?.let {
// On Android 10+, if the app doesn't have permission to modify
// or delete an item, it returns an `IntentSender` that we can
// use here to prompt the user to grant permission to delete (or modify)
// the image.
startIntentSenderForResult(
intentSender,
DELETE_PERMISSION_REQUEST,
null,
0,
0,
0,
null
)
}
})


Here, we use startIntentSenderForResult() on Activity to invoke the IntentSender.
If the IntentSender wraps an activity Intent, startIntentSenderForResult()
will call startActivityForResult() on that underlying Intent. In the case
of the IntentSender from the RecoverableSecurityException, this will bring up a UI to
allow the user to grant your app rights to modify the content affected by
the failed delete() request. And, if onActivityResult() gets RESULT_OK
for the startIntentSenderForResult() call, this means the user granted you
permission, and you can retry your delete(), which should now succeed.



Many thanks to Nicole Borelli of Google for creating and posting this sample code!





Previously, I covered:




The basics of using the Storage Access Framework
Getting durable access to the selected content
Working with DocumentFile for individual documents
Working with document trees
Working with DocumentsContract
Problems with the SAF API
A specific problem with listFiles() on DocumentFile
Storing content using MediaStore
Reading content from the MediaStore


In the next MediaStore post, I will look at the new Downloads category of
���media��� and how we can use that in Android 10+.

 •  0 comments  •  flag
Share on Twitter
Published on January 05, 2020 05:27

December 29, 2019

Scoped Storage Stories: Reading via MediaStore

Android 10 is greatly restricting access to external storage
via filesystem APIs. Instead, we need to use other APIs to work with content.
This is the ninth post in a series where we will explore how to work with those
alternatives, now looking at MediaStore options.





In our last episode,
we looked at saving content to a Uri obtained by insert() into the MediaStore.
If we put data into the MediaStore, it may also prove useful to get data
back out of it.



The basic recipe for this has not changed from past Android versions. You can use a ContentResolver
to query() a MediaStore collection of relevance.



For example, this sample project
has its own edition of VideoRepository that
will query() the MediaStore for all videos on external storage:



suspend fun listTitles(): List<String>? =
withContext(Dispatchers.IO) {
val resolver = context.contentResolver

resolver.query(collection, PROJECTION, null, null, SORT_ORDER)
?.use { cursor ->
cursor.mapToList { it.getString(0) }
}
}


In this function, inside of a coroutine, we:





Obtain a ContentResolver from a suitable Context




Call query() on that ContentResolver to obtain a Cursor with interesting stuff




Get the 0th column out of each Cursor row and return that in a List





PROJECTION and SORT_ORDER are just for the title:



private val PROJECTION = arrayOf(MediaStore.Video.Media.TITLE)
private const val SORT_ORDER = MediaStore.Video.Media.TITLE


���and mapToList() just iterates over the Cursor rows and applies the lambda
expression to each row:



private fun <T : Any> Cursor.mapToList(predicate: (Cursor) -> T): List<T> =
generateSequence { if (moveToNext()) predicate(this) else null }
.toList()


collection, though, varies a bit by OS version, as on Android 10+ we can
get dedicated Uri values for media by storage volume. Here, we get the value
for external storage:



private val collection =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStore.Video.Media.getContentUri(
MediaStore.VOLUME_EXTERNAL
)
} else {
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
}




Where things get even more interesting is how this bit of code relates to
permissions.



On Android 9 and older devices (back to Android 4.4 IIRC), if you run that code
without READ_EXTERNAL_STORAGE (or perhaps WRITE_EXTERNAL_STORAGE), you crash
with a SecurityException. However, once you have that permission, your query will
return a list of the titles of all videos available in that collection.



On Android 10, you can run that code with no permissions. However, you will
only get back those pieces of content that your app inserted into the MediaStore
collection. Only if you hold READ_EXTERNAL_STORAGE will you be able to get the list
of all titles in that collection, including those placed there by other apps or by the
user (e.g., via USB cable).



You can see this in action if you run that sample app on an Android 10 device. The
UI is mostly a RecyclerView showing the list of titles, and that will be empty
the first time you run the app. If you click the ���add to library��� toolbar button,
the app will copy a small MP4 file from assets/ into the MediaStore, using the
techniques from the previous blog post.
You will then see ���test��� show up in the RecyclerView, as the media���s filename
is test.mp4. If you click the ���all inclusive��� action bar item, you will be
prompted to grant read access to external storage. After that, the list will show
all videos in external storage, because now the app has READ_EXTERNAL_STORAGE
rights and can access all the content.



In contrast, if you run that app on Android 9 or older devices, you will be prompted
to grant READ_EXTERNAL_STORAGE right away, as otherwise we cannot query()
the MediaStore





This sample app just needs the titles. If you wanted to actually do something
with the content, such as play it back using ExoPlayer, you can get a Uri
on the content by:





Including MediaStore.Video.Media._ID in your projection




Obtaining the _ID column value for the piece of content of interest




Pass that plus the collection Uri that you queried to ContentUris.withAppendedId()
to get the Uri for that particular piece of content





If you were able to conduct the query, you will be able to read in the content.
If you want to use a third-party app to do something with the content ��� such as
passing the Uri to a video player via ACTION_VIEW ��� be sure to add
the Intent.FLAG_GRANT_READ_URI_PERMISSION flag to the Intent, to pass along
read access rights. Otherwise, the video player app may not have permission to work
with that Uri.





Previously, I covered:




The basics of using the Storage Access Framework
Getting durable access to the selected content
Working with DocumentFile for individual documents
Working with document trees
Working with DocumentsContract
Problems with the SAF API
A specific problem with listFiles() on DocumentFile
Storing content using MediaStore


In the next MediaStore post, I will look at modifying content placed on the
device by the user or by other apps, as this is a bit different than storing
your own content.

 •  0 comments  •  flag
Share on Twitter
Published on December 29, 2019 08:25

December 21, 2019

Scoped Storage Stories: Storing via MediaStore

Android 10 is greatly restricting access to external storage
via filesystem APIs. Instead, we need to use other APIs to work with content.
This is the eighth post in a series where we will explore how to work with those
alternatives, now looking at MediaStore options.





The Storage Access Framework offers the user the most flexibility of where your
app���s content should be placed. One key downside is that it requires the user
to interact with some system UI to choose where to place the content.



getExternalFilesDir() and related Context methods avoid that UI. However,
the files that you create in these locations get removed when the app is uninstalled.
That may not be appropriate for all types of content, as the user may get irritated
if uninstalling the app deletes ���their��� files.



The third option is MediaStore. As with the Storage Access Framework, content
you store via MediaStore will remain after the app is uninstalled. And, like
getExternalFilesDir() and kin, you are not forced to display some system UI.
However, MediaStore is restricted (mostly) to just media: images, audio files, and video files.



The basic recipe for storing content using MediaStore is:





Create a ContentValues describing the content




insert() that into a MediaStore collection using a ContentResolver




Use the Uri that insert() returns to open an OutputStream (again
using ContentResolver)




Write your content to that OutputStream





This is definitely more complicated than simply using a File, but it���s not that bad.



This sample app
(from Elements of Android Q) lets you download
archived conference videos from my Web server. The app is set up to use
Environment.getExternalStoragePublicDirectory() on older devices but use
MediaStore on Android 10 and above.



First, you need a collection Uri from the MediaStore, representing where
you want to store the content. Conference videos are videos (surprise!), so
the VideoRepository
uses MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
to get a Uri representing the storage location for video content on external
storage. getContentUri() is a new method in Android 10, and you can use it to
work both with external and removable storage.



The VideoRepository has a downloadQ() function that derives a URL of a video from
a filename and downloads the video into that collection:



private suspend fun downloadQ(filename: String): Uri =
withContext(Dispatchers.IO) {
val url = URL_BASE + filename
val response = ok.newCall(Request.Builder().url(url).build()).execute()

if (response.isSuccessful) {
val values = ContentValues().apply {
put(MediaStore.Video.Media.DISPLAY_NAME, filename)
put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/ConferenceVideos")
put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
put(MediaStore.Video.Media.IS_PENDING, 1)
}

val resolver = context.contentResolver
val uri = resolver.insert(collection, values)

uri?.let {
resolver.openOutputStream(uri)?.use { outputStream ->
val sink = Okio.buffer(Okio.sink(outputStream))

response.body()?.source()?.let { sink.writeAll(it) }
sink.close()
}

values.clear()
values.put(MediaStore.Video.Media.IS_PENDING, 0)
resolver.update(uri, values, null, null)
} ?: throw RuntimeException("MediaStore failed for some reason")

uri
} else {
throw RuntimeException("OkHttp failed for some reason")
}
}


This sample uses coroutines, so all the
work is wrapped in a Dispatchers.IO coroutine context, to arrange for it to be
performed on a backgorund thread.



downloadQ() gets the URL based on the filename, then uses OkHttp to make the
HTTP GET request to download it.



If we get a 200 OK response, we then set up a ContentValues representing this new
bit of media. The DISPLAY_NAME is just our filename, and the MIME_TYPE is tied
to the type of media (in this case, an MP4 video). RELATIVE_PATH is new to Android 10
and allows you to specify a sub-folder within the collection where this content should
be placed ��� in this case, we are asking for a ConferenceVideos folder within
Movies. IS_PENDING is also new to Android 10, where a value of 1 means that
the content is not yet ready for use, as we are still downloading it.



After we insert() that ContentValues using a ContentResolver, we have a Uri
representing where the MediaStore wants us to place the actual content. We can
use the ContentResolver and openOutputStream() to get an OutputStream on
that location. From there, we use Okio to slurp down the video content and stream it
out to the, um, stream. In a streaming fashion. Streamily.



Finally, we replace the ContentValues content with IS_PENDING set to 0
and use the ContentResolver to update() the values associated with our Uri.
This flip IS_PENDING to false, meaning that the content is ready for use.



This function can then be called from a viewmodel or other place that has a suitable
coroutine scope (e.g., viewModelScope).





Previously, I covered:




The basics of using the Storage Access Framework
Getting durable access to the selected content
Working with DocumentFile for individual documents
Working with document trees
Working with DocumentsContract
Problems with the SAF API
A specific problem with listFiles() on DocumentFile


In the next MediaStore post, I will look at how we can find our content again
from the MediaStore, such as allowing the user to play back a video that we
downloaded previously.

 •  0 comments  •  flag
Share on Twitter
Published on December 21, 2019 09:50

December 15, 2019

���Exploring Android��� Version 1.0 Released

Subscribers now have
access to an update to Exploring Android,
known as Version 1.0, in PDF, EPUB, and MOBI/Kindle formats, in addition to the
online reader. Just log into
your Warescription page and download
away, or set up an account and subscribe!





Mostly, this update contains bug fixes, along with changes to support Android Studio 3.5.3 and
updating many of the dependencies.



This book will be updated every few months from this point forward, mostly
after Android Studio updates. Those updates will:





Address any changes in Android Studio as it affects people working on the tutorials




Add in a few more tutorials, such as WorkManager




Cover any impacts of Android R and other future Android releases




Update the dependencies and tweak the implementation of the app based on those dependency changes

 •  0 comments  •  flag
Share on Twitter
Published on December 15, 2019 07:50

December 14, 2019

Scoped Storage Stories: listFiles() Woe

Android 10 is greatly restricting access to external storage
via filesystem APIs. Instead, we need to use other APIs to work with content.
This is the seventh post in a series where we will explore how to work with those
alternatives, starting with the Storage Access Framework (SAF).





I thought I was going to switch to covering the MediaStore approach for storing
content in Android 10. Torsten Grote, though, had other ideas. ����



On Twitter, Torsten pointed out a problem with listFiles() in DocumentFile:
it might not list the files. This problem serves as a nice illustration of the
twisty nature of the Storage Access Framework APIs and how not everything will
work the way that you might expect.



Once developers make the leap into using the Storage Access Framework, some
come to the unfortunate conclusion that the SAF is only for on-device storage.
This is not the case.



Remember that when you use an Intent action like ACTION_OPEN_DOCUMENT_TREE,
the user can choose from any document provider available on their device. For
many users, there will be few options other than the built-in providers that
handle external/removable storage and media. But, some users may install apps
that offer other document providers, particularly for cloud-based document stores.



As a result, the Storage Access Framework APIs ��� as defined in DocumentsContract
and DocumentsProvider ��� are set up to support cloud-based document stores.
That manifests itself in many ways, such as the abstract notion of ���document tree���
(since a cloud provider might not have directories) and a ���display name��� for
content (since a cloud provider might not use filenames).



Under the covers, listFiles() uses
buildChildDocumentsUriUsingTree() on DocumentsContract.
This returns a Uri that can be queries to get the child documents based on
a tree Uri, such as the one that you would get from ACTION_OPEN_DOCUMENT_TREE.
That, in turn, will call queryChildDocuments() on DocumentsProvider
for the provider associated with the tree Uri. This returns a Cursor with
the child document details��� sometimes.



The documentation for queryChildDocuments() contains this note:




If your provider is cloud-based, and you have data cached locally, you may return the local data immediately, setting DocumentsContract#EXTRA_LOADING on Cursor extras to indicate that you are still fetching additional data. Then, when the network data is available, you can send a change notification to trigger a requery and return the complete contents. To return a Cursor with extras, you need to extend and override Cursor#getExtras().




(if you are asking yourself ���Cursors have extras?���, yes, a Cursor can have extras,
though normally it does not)



DocumentFile ignores this, neither using it nor returning it to the caller of
listFiles(). As a result, users of listFiles() have no way to know that their
list of files does not represent the full list, but rather some subset (or even
an empty list) while the provider is fetching the details. Torsten indicated
that the Nextcloud app has a document provider that
will return empty lists at the outset, though I have not attempted to reproduce
that behavior. It fits the API, though, so not only might Nextcloud behave like this,
other cloud document providers might as well. And, it���s all perfectly legitimate.



One can imagine some future KTX version of listFiles() that would return a Flow.
It would register a ContentObserver to find out about file changes and emit a fresh
list on the Flow when changes are detected. Unfortunately, DocumentFile does not
have that today.



Lacking that, you still have a few options, including:





Ignore the problem




Ignore the problem but offer some sort of ���refresh��� option that the user can use
to cause you to call listFiles() again, hoping that you will get the actual list
of files on a subsequent call




Use listFiles() but also query the buildChildDocumentsUriUsingTree() Uri
yourself, just to get the EXTRA_LOADING flag, so you can take that into account




Create your own reactive listFiles() alternative that uses EXTRA_LOADING
and a ContentObserver to handle both the case when all the files are listed up
front and the case where the list of files is loading





This is one area where the DocumentFile API is tuned more towards local providers
and does not handle cloud providers all that well. The downside of offering an API
that resembles File is that File works with filesystem contents, and its API
does not match what we might want for a cloud-based document store. Perhaps some
future library will offer an easy API that is more cloud-aware.





Previously, I covered:




The basics of using the Storage Access Framework
Getting durable access to the selected content
Working with DocumentFile for individual documents
Working with document trees
Working with DocumentsContract
Problems with the SAF API


In my next post in the series, I will try again to start covering MediaStore
as an alternative to the SAF for accessing and creating content.

 •  0 comments  •  flag
Share on Twitter
Published on December 14, 2019 07:13