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!
The entire series of “Scoped Storage Stories” posts includes posts on:
- 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()
onDocumentFile
- Storing content using
MediaStore
- Reading content from the
MediaStore
- Modifying
MediaStore
content from other apps - Limitations of
MediaStore.Downloads
- The undocumented
Documents
option - More on
RecoverableSecurityException
- How to modify more metadata in
MediaStore