Scoped Storage Stories: MediaStore Metadata Madness

Android 10 and higher are greatly restricting access to external storage via filesystem APIs. Instead, we need to use other APIs to work with content. This is the 14th post in a seemingly never-ending series, where we will explore how to work with those alternatives.


Previously, I wrote about modifying the content of other apps and responding to a RecoverableSecurityException. In that latter post, I wrote:

Unfortunately, that RecoverableSecurityException is not going to do you a lot of good in this case… because DESCRIPTION cannot be modified, even if the user grants your app permission to modify the content. For some columns, MediaStore just refuses to apply the update.

It turns out that there is a recipe for making this work, though the approach is bizarre:

  • First, update the content to set IS_PENDING to 1

  • Then, update the content to modify your desired bits of metadata and also set IS_PENDING back to 0

IS_PENDING was added in Android 10. The idea is that you set IS_PENDING to 1 to indicate that you are setting up the content, such as copying data to the Uri supplied by the MediaStore. Apps, when querying the MediaStore, can opt out of seeing pending items, as they may not be useful yet. Later, you flip IS_PENDING to 0, to indicate that the content is ready for use.

Inexplicably, in an update() call on ContentResolver for MediaStore, where you flip IS_PENDING from 1 to 0, you can also update other metadata columns like DESCRIPTION. If IS_PENDING is not already 1, this does not work, so you wind up having to set IS_PENDING to 1 first, then set it to 0 while updating the rest of your metadata.

I’m sure that this system makes sense to somebody… but not me. However, it works on Android 10. I am still running into problems in general with MediaStore and RecoverableSecurityException on R DP2, so I do not know if anything with this changes there.

The v2 tagged version of my MovieLister sample demonstrates this approach. VideoRepository uses a setMetadata() function to update metadata using this IS_PENDING hack:

private suspend fun setMetadata(id: Long, values: ContentValues) =
  withContext(Dispatchers.IO) {
    val uri = ContentUris.withAppendedId(collection, id)

    if (Build.VERSION.SDK_INT >= 29) {
      val tempValues = ContentValues().apply {
        put(MediaStore.Video.Media.IS_PENDING, 1)
      }

      context.contentResolver.update(uri, tempValues, null, null)
    }

    val updatedValues =
      if (Build.VERSION.SDK_INT >= 29) {
        ContentValues(values).apply {
          put(MediaStore.Video.Media.IS_PENDING, 0)
        }
      } else {
        values
      }

    context.contentResolver.update(uri, updatedValues, null, null)
  }

You pass in the ContentValues that you wish to apply, and setMetadata():

  • Sets IS_PENDING to 1
  • Makes a new ContentValues based on your passed-in one
  • Adds IS_PENDING as 0 to that copy of the ContentValues
  • Updates the item with that augmented ContentValues copy
  • Only goes through all that crap on Android 10 and higher, skipping it for older devices

To me, this approach screams “unintended side effect”, and I would not be surprised if it breaks in Android R or future versions. Also, bear in mind that some of these metadata values may get clobbered if the MediaStore re-indexes the content. update() is updating the MediaStore, not metadata that might be in the media itself (e.g., EXIF tags in a JPEG).

But, at least for Android 10, it is an option that you can consider.

UPDATE 2020-04-11: Stack Overflow user CodeRed pointed out that you can see what values are mutable without IS_PENDING by examining the sMutableColumns field in MediaProvider. In Android 10, this looks like:

  private static final ArraySet<String> sMutableColumns = new ArraySet<>();
  {
      sMutableColumns.add(MediaStore.MediaColumns.DATA);
      sMutableColumns.add(MediaStore.MediaColumns.RELATIVE_PATH);
      sMutableColumns.add(MediaStore.MediaColumns.DISPLAY_NAME);
      sMutableColumns.add(MediaStore.MediaColumns.IS_PENDING);
      sMutableColumns.add(MediaStore.MediaColumns.IS_TRASHED);
      sMutableColumns.add(MediaStore.MediaColumns.DATE_EXPIRES);
      sMutableColumns.add(MediaStore.MediaColumns.PRIMARY_DIRECTORY);
      sMutableColumns.add(MediaStore.MediaColumns.SECONDARY_DIRECTORY);
      sMutableColumns.add(MediaStore.Audio.AudioColumns.BOOKMARK);
      sMutableColumns.add(MediaStore.Video.VideoColumns.TAGS);
      sMutableColumns.add(MediaStore.Video.VideoColumns.CATEGORY);
      sMutableColumns.add(MediaStore.Video.VideoColumns.BOOKMARK);
      sMutableColumns.add(MediaStore.Audio.Playlists.NAME);
      sMutableColumns.add(MediaStore.Audio.Playlists.Members.AUDIO_ID);
      sMutableColumns.add(MediaStore.Audio.Playlists.Members.PLAY_ORDER);
      sMutableColumns.add(MediaStore.Files.FileColumns.MIME_TYPE);
      sMutableColumns.add(MediaStore.Files.FileColumns.MEDIA_TYPE);
  }

Anything else — like DESCRIPTION — requires modifying it after going through the IS_PENDING hop described in this post.


The entire series of “Scoped Storage Stories” posts includes posts on: