Scoped Storage Tutorial for Android 11: Deep Dive

Scoped storage is mandatory for all apps targeting Android 11. In this tutorial, you’ll learn how to implement the latest storage APIs in Android 11 by adding features to a meme-generating app. By Carlos Mota.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

Starring a File

This is particularly handy for defining priorities on a list. You’ll first add the capability to set an item as a favorite and then create a filter that only shows starred media.

Setting an Item as a Favorite

To add the ability to star a favorite item, add the following in action_main.xml:

<item
  android:id="@+id/action_favorite"
  android:title="@string/action_favorite_add"
  android:showAsAction="never"
  tools:ignore="AppCompatResource" />

Now add the following logic to onActionItemClicked, which is in MainFragment.kt:

R.id.action_favorite -> {
  addToFavorites()
  true
}

addToFavorites is called when there are media files selected via long-press and the user selects this option from the context menu.

Add the method to add/remove items from favorites in MainFragment.kt:

private fun addToFavorites() {
  //1
  if (!hasSdkHigherThan(Build.VERSION_CODES.Q)) {
    Snackbar.make(
               binding.root, 
               R.string.not_available_feature, 
               Snackbar.LENGTH_SHORT).show()
    return
  }

  //2
  val media = imageAdapter.currentList.filter {
    tracker.selection.contains("${it.id}")
  }

  //3
  val state = !(media.isNotEmpty() && media[0].favorite)
  //4
  viewModel.requestFavoriteMedia(media, state)
  //5
  actionMode?.finish()
}

Here’s a step-by-step breakdown of this logic:

  1. This feature is only available in Android 11. If the app is running on a lower version, it’ll display a message and use return to leave the method.
  2. To add media to favorites, first you need to know which files to update. Retrieve the list by filtering all media with the IDs of the selected files.
  3. You can select both images that are already starred and those that aren’t. The value of the first image selected takes precedence. In other words, if the first image selected is already a favorite, it’ll be removed from this list. Otherwise, it’ll be added.
  4. Call requestFavoriteMedia to add/remove these files from favorites.
  5. Close the action mode.

Displaying the Favorites

Now that images can be added to favorites, the app needs a way to display them. To retreive a filtered list of favorites, head to MainViewModel.kt and add requestFavoriteMedia:

fun requestFavoriteMedia(media: List<Media>, state: Boolean) {
  val intentSender = FileOperations.addToFavorites(
          getApplication<Application>(), 
          media, 
          state)
  _actions.postValue(
          MainAction.ScopedPermissionRequired(
                  intentSender, 
                  ModificationType.FAVORITE))
}

With scoped storage, to make any modification on a file not created by the app itself, it’s necessary to ask the user for permission. This is why there’s an intentSender object returned on addToFavorites.

Add addToFavorites to FileOperations.kt:

@SuppressLint("NewApi") //method only call from API 30 onwards
fun addToFavorites(context: Context, media: List<Media>, state: Boolean): IntentSender {
  val uris = media.map { it.uri }
  return MediaStore.createFavoriteRequest(
             context.contentResolver, 
             uris, 
             state).intentSender
}

The code above calls MediaStore.createFavoriteRequest so the files can be added or removed from favorites depending on the value of state. Add the value for FAVORITE to ModificationType in actions.kt:

FAVORITE,

Then add the following verification to requestScopedPermission inside MainFragment.kt:

ModificationType.FAVORITE -> REQUEST_PERMISSION_FAVORITE

The code above asks the user for permission.

Now add a new filter to retrieve only the images added to favorites. Start by opening menu_main.xml and adding the following:

<item
  android:id="@+id/filter_favorite"
  android:title="@string/filter_favorite"
  app:showAsAction="never"/>

This will be a new entry point shown in the context menu. Open MainFragment.kt, and in onOptionsItemSelected, add:

R.id.filter_favorite -> {
  loadFavorites()
  true
}

When the user selects this option, only favorited media appears. Now, define the corresponding method:

private fun loadFavorites() {
  if (!hasSdkHigherThan(Build.VERSION_CODES.Q)) {
    Snackbar.make(
               binding.root, 
               R.string.not_available_feature, 
               Snackbar.LENGTH_SHORT).show()
    return
  }
  viewModel.loadFavorites()
}

If the app is running on a device with a version that doesn’t support favorites, a message with the text “Feature only available on Android 11” is displayed. Alternatively, you can hide this option.

loadFavoritesis defined in MainViewModel:

@RequiresApi(Build.VERSION_CODES.R)
fun loadFavorites() {
  viewModelScope.launch {
    val mediaList = FileOperations.queryFavoriteMedia(
                                     getApplication<Application>())
    _actions.postValue(MainAction.FavoriteChanged(mediaList))
  }
}

The code above calls FileOperations.queryFavoriteMedia to load only the starred files. Use RequiresApi to warn the developer that this method should only be called on Android 11 and above.

Open FileOperations.kt and add this function:

@RequiresApi(Build.VERSION_CODES.R)
suspend fun queryFavoriteMedia(context: Context): List<Media> {
  val favorite = mutableListOf<Media>()
  withContext(Dispatchers.IO) {
    val selection = "${MediaStore.MediaColumns.IS_FAVORITE} = 1"
    favorite.addAll(queryImagesOnDevice(context, selection))
    favorite.addAll(queryVideosOnDevice(context, selection))
  }
  return favorite
}

Instead of fetching all images and videos, add a condition to only retrieve the ones with the attribute IS_FAVORITE set as 1 on MediaStore. This ensures the query is optimized to return only the data you want — there’s no need for additional checks.

You’ve defined the query. Now, add a new data class, FavoriteChanged, to MainAction inside the actions.kt:

data class FavoriteChanged(val favorites: List<Media>) : MainAction()

When the list of favorites is available, notify the UI to reload the gallery with this new favorites list. In MainFragment.kt, update handleAction:

private fun handleAction(action: MainAction) {
  when (action) {
    is MainAction.FavoriteChanged -> {
      imageAdapter.submitList(action.favorites)
      if (action.favorites.isEmpty()) {
        Snackbar.make(binding.root, R.string.no_favorite_media, 
                      Snackbar.LENGTH_SHORT).show()
      }
    }
  }
}

It’s time to test this new feature! Hit compile and run and add your best memes to your favorites.

Adding images to favorites

Now that you know how to star a file, it’s time to learn how to trash one.

Trashing a File

Trashing a file is not the same as a delete operation. Deleting a file completely removes it from the system, whereas trashing a file adds it to a temporary recycle bin, like what happens on a computer. The file will stay there for 30 days, and if no further action is taken, the system will automatically delete it after that time.

The logic behind trashing a file is like that of starring a file, as you’ll see.

Adding a File to the Trash
Give the app the capability to place a file in the trash first. Open action_main.xml and add:

<item
  android:id="@+id/action_trash"
  android:title="@string/action_trash_add"
  android:showAsAction="never"
  tools:ignore="AppCompatResource"/>

The code above adds the entry point for trashing a file. Now open MainFragment.kt, and on onActionItemClicked, define its action:

R.id.action_trash -> {
  addToTrash()
  true
}

This will call addToTrash to remove the file. After this method, add addToTrash:

private fun addToTrash() {
  //1
  if (!hasSdkHigherThan(Build.VERSION_CODES.Q)) {
    Snackbar.make(
               binding.root, 
               R.string.not_available_feature, 
               Snackbar.LENGTH_SHORT).show()
    return
  }

  //2
  val media = imageAdapter.currentList.filter {
    tracker.selection.contains("${it.id}")
  }

  //3
  val state = !(media.isNotEmpty() && media[0].trashed)
  //4
  viewModel.requestTrashMedia(media, state)
  //5
  actionMode?.finish()
}

Let’s analyze this code step by step:

  1. This feature is only available on Android 11. If the app is running a lower version, it displays a message saying the current OS doesn’t support trashing a file.
  2. Select the list of media files to send to the trash.
  3. After retrieving the list, identify the files that will be restored or removed. Look at the status of the first file in the list. If it’s already in the trash, all the files will be restored. Otherwise, they’ll all be removed.
  4. Call requestTrashMedia to restore/remove these files.
  5. Close the action mode.

Now, define requestTrashMedia in MainViewModel.kt:

fun requestTrashMedia(media: List<Media>, state: Boolean) {
  val intentSender = FileOperations.addToTrash(
                                      getApplication<Application>(), 
                                      media, 
                                      state)
  _actions.postValue(MainAction.ScopedPermissionRequired(
                                  intentSender, 
                                  ModificationType.TRASH))
}

Remember that if you’re trying to modify a file your app didn’t create, you need to ask for permission. To obtain permission, addToTrash returns intentSender to prompt the user.

Add addToTrash to FileOperations.kt:

@SuppressLint("NewApi") //method only call from API 30 onwards
fun addToTrash(context: Context, media: List<Media>, state: Boolean): 
  IntentSender {
  val uris = media.map { it.uri }
  return MediaStore.createTrashRequest(
                      context.contentResolver, 
                      uris, 
                      state).intentSender
}

To make the call to MediaStore.createTrashRequest, retrieve the files’ Uris from the list of media, along with the state, which is true if the files will be trashed, and false otherwise.

Open actions.kt and update ModificationType to hold this new update type, TRASH:

TRASH

On MainFragment.kt, add the following to requestScopedPermission:

ModificationType.TRASH -> REQUEST_PERMISSION_TRASH

The code above will prompt the user to grant permission to these files.

Now that you’ve added the logic to add/remove a file to/from the trash, the next step is to add a new filter to see all the files marked for removal.

Viewing the Files in the Trash

To filter the files and view only the trashed files, first add the option to the menu. In menu_main.xml add a new item:

<item
  android:id="@+id/filter_trash"
  android:title="@string/filter_trash"
  app:showAsAction="never"/>

This creates an entry point. Now open MainFragment.kt, and on onOptionsItemSelected, define its action:

R.id.filter_trash -> {
  loadTrashed()
  true
}

The code above will call loadTrashed. After onOptionsItemSelected, add:

private fun loadTrashed() {
  if (!hasSdkHigherThan(Build.VERSION_CODES.Q)) {
    Snackbar.make(
               binding.root, 
               R.string.not_available_feature, 
               Snackbar.LENGTH_SHORT).show()
    return
  }

  viewModel.loadTrashed()
}

If the device has Android 11, the app will load all the items in the trash through the call to loadTrashed.

In MainViewModel.kt, add the following function:

@RequiresApi(Build.VERSION_CODES.R)
fun loadTrashed() {
  viewModelScope.launch {
    val mediaList = FileOperations.queryTrashedMedia(
                                     getApplication<Application>())
    _actions.postValue(MainAction.TrashedChanged(mediaList))
  }
}

This queries the system for all the trashed media, and when you receive this list, the UI reloads the gallery to show the filtered view.

The logic to implement this query is a big different from the one for querying images. Navigate to FileOperations.kt and add queryTrashedMedia:

@RequiresApi(Build.VERSION_CODES.R)
suspend fun queryTrashedMedia(context: Context): List<Media> {
  val trashed = mutableListOf<Media>()

  withContext(Dispatchers.IO) {
    trashed.addAll(queryTrashedMediaOnDevice(
                     context, 
                     MediaStore.Images.Media.EXTERNAL_CONTENT_URI))
    trashed.addAll(queryTrashedMediaOnDevice(
                     context, 
                     MediaStore.Video.Media.EXTERNAL_CONTENT_URI))
  }
  return trashed
}

In the code above, instead of having two separate methods for querying trashed media, you’ll use the same method — queryTrashedMediaOnDevice — and send different EXTERNAL_CONTENT_URIs depending on the type of query.

Now, add queryTrashedMediaOnDevice to FileOperations.

@RequiresApi(Build.VERSION_CODES.R)
suspend fun queryTrashedMediaOnDevice(context: Context, contentUri: Uri): List<Media> {
  val media = mutableListOf<Media>()
  withContext(Dispatchers.IO) {
    //1
    val projection = arrayOf(MediaStore.MediaColumns._ID,
        MediaStore.MediaColumns.RELATIVE_PATH,
        MediaStore.MediaColumns.DISPLAY_NAME,
        MediaStore.MediaColumns.SIZE,
        MediaStore.MediaColumns.MIME_TYPE,
        MediaStore.MediaColumns.WIDTH,
        MediaStore.MediaColumns.HEIGHT,
        MediaStore.MediaColumns.DATE_MODIFIED,
        MediaStore.MediaColumns.IS_FAVORITE,
        MediaStore.MediaColumns.IS_TRASHED)

    //2
    val bundle = Bundle()
    bundle.putInt("android:query-arg-match-trashed", 1)
    bundle.putString("android:query-arg-sql-selection", 
                       "${MediaStore.MediaColumns.IS_TRASHED} = 1")
    bundle.putString("android:query-arg-sql-sort-order", 
                       "${MediaStore.MediaColumns.DATE_MODIFIED} DESC")

    //3
    context.contentResolver.query(
        contentUri,
        projection,
        bundle,
        null
    )?.use { cursor ->

      //4
      while (cursor.moveToNext()) {
        val id = cursor.getLong(cursor.getColumnIndex(
                                  MediaStore.MediaColumns._ID))
        val path = cursor.getString(cursor.getColumnIndex(
                                  MediaStore.MediaColumns.RELATIVE_PATH))
        val name = cursor.getString(cursor.getColumnIndex(
                                  MediaStore.MediaColumns.DISPLAY_NAME))
        val size = cursor.getString(cursor.getColumnIndex(
                                  MediaStore.MediaColumns.SIZE))
        val mimeType = cursor.getString(cursor.getColumnIndex(
                                  MediaStore.MediaColumns.MIME_TYPE))
        val width = cursor.getString(cursor.getColumnIndex(
                                  MediaStore.MediaColumns.WIDTH))
        val height = cursor.getString(cursor.getColumnIndex(
                                  MediaStore.MediaColumns.HEIGHT))
        val date = cursor.getString(cursor.getColumnIndex(
                                  MediaStore.MediaColumns.DATE_MODIFIED))
        val favorite = cursor.getString(cursor.getColumnIndex(
                                  MediaStore.MediaColumns.IS_FAVORITE))
        val uri = ContentUris.withAppendedId(contentUri, id)
        // Discard invalid images that might exist on the device
        if (size == null) {
          continue
        }
        media += Media(id, 
                   uri, 
                   path, 
                   name, 
                   size, 
                   mimeType, 
                   width, 
                   height, 
                   date, 
                   favorite == "1", 
                   true)
      }
      cursor.close()
    }
  }
  return media
}

Let’s break down the logic in the code above into small steps:

  1. The projection defines the attributes retrieved from the MediaStore tables. There’s an additional IS_TRASHED used internally to select only the elements in the trash.
  2. Compared to the query for images and videos, this one is a bit different. The images and videos don’t account for elements in the trash, and since you want those, you’ll need to follow a different approach. This is the reason to create this function. Use bundle with these arguments defined to get all the trashed media on disk.
  3. Execute the query with all the above parameters defined.
  4. Retrieve all the media, iterate over the returned cursor and save this data to update the UI.

Finally, add TrashedChanged to MainAction inside actions.kt:

sealed class MainAction {
  data class TrashedChanged(val trashed: List<Media>) : MainAction()
}

This will notify the UI when there’s a new trashed list to show. In MainFragment.kt, update handleAction:

private fun handleAction(action: MainAction) {
  when (action) {
    is MainAction.TrashedChanged -> {
      imageAdapter.submitList(action.trashed)
      if (action.trashed.isEmpty()) {
        Snackbar.make(binding.root, R.string.no_trashed_media, 
                      Snackbar.LENGTH_SHORT).show()
      }
    }
  }
}

All done! Build and run. :]

Adding images to trash