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: