All videos. All books. One low price.

Get unlimited access to all video courses and books on this site with the new raywenderlich.com Ultimate Subscription. Plans start at just $19.99/month.

Home Android & Kotlin Tutorials

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.

5/5 2 Ratings

Version

  • Kotlin 1.4, Android 10.0, Android Studio 4.2

Scoped storage was one of the most significant features introduced in Android 10. It included an opt-out mechanism, but with Android 11, it’s now mandatory that all apps targeting this version have scoped storage implemented.

These new improvements have privacy in mind. Whereas granting runtime storage permission means an app can access almost every file on disk, scoped storage aims to avoid this. Now, if an app wants to access a file it didn’t create, a user needs to give it explicit access. Additionally, to reduce the number of files scattered across the disk, there are specific folders for storing them. When uninstalling an app, all the data outside those directories will be removed. This is something your free disk space appreciates. :]

The goal of this tutorial is to dive into scoped storage and show you more of its advanced functionalities. More specifically, you’ll learn:

  • How to migrate your app data.
  • Restricting and broadening access.
  • How file managers and backup apps can access the file system.
  • Limitations of the new requirements.

You’ll do this with Le Memeify, an app that displays all the images on your device and allows you to make incredible memes. It combines all the concepts of scoped storage and provides a great experience while testing. :] Feel free to share the best memes with us on Twitter @rwenderlich.

Note: This tutorial assumes you’re familiar with scoped storage. If you’re new to this concept, read Preparing for Scoped Storage before you continue.

It also uses Kotlin coroutines, navigation architecture component, View Binding and LiveData and ViewModel. If you’re unfamiliar with these concepts, take a look at these tutorials.

Getting Started

Access the tutorial files by clicking on Download Materials at the top or bottom of this page. You’ll find two projects inside the ZIP file. Starter has the skeleton of the app you’ll build, and Final gives you something to compare your code to when you’re done.

Scoped Storage App Images gallery

The image above shows what you’ll build!

Understanding the Project Structure

To understand the project structure, first open the Starter project in Android Studio and wait for it to synchronize.

The directory structure of the project.

You’ll see a set of subfolders and important files:

  • model: This is the data object used to represent an image. It contains the URI, modified date, size, etc.
  • ui: In this folder, you’ll find the activity, fragments, view models and adapters you’ll use to allow your users to view and interact with the app’s data. This folder also has two subfolders for the two main screens of the app: details and images.
  • ui/actions.kt: Here you’ll find the Kotlin sealed classes for any action a view might take. This makes things nice and tidy by explicitly listing these actions.
  • FileOperations.kt: This file contains the class that defines all the file operations.
  • Utils.kt: This file has the set of utility methods you’ll use throughout the project.

Running Le Memeify

To access the images on your device, you must grant the storage permission when prompted.

Build and run. After you accept the permissions, you’ll see a screen like this:

Images gallery and selection

Your screen will show different images, of course. :]

Introducing Scoped Storage

Scoped storage brings two major changes. First, you no longer have access to a file via its path. Instead, you need to use its Uri. Second, if you want to modify a file not created by your app, you need to ask the user for permission.

There are two possibilities for doing this. One is via the MediaStore API that allows you to query the device for images, videos and audio. The other is using the Storage Access Framework (SAF), which opens the native file explorer and allows you to request access either for a specific file or for its root folder — depending on whether you’re using the action ACTION_OPEN_DOCUMENT or ACTION_OPEN_DOCUMENT_TREE, respectively.

What’s New in Android 11

Android 11 introduces a new set of features focused on files:

  • Bulk operations
  • Starring a file
  • Trashing a file

In the next few sections, you’ll learn more about each of these.

Bulk Operations

One of the limitations on Android 10 is the lack of support for bulk operations performed on files. Every time you want to do something to multiple files, you need to iterate over a list and ask the user for consent for each individual file.

Android 11 added two new methods to solve this:

  • createWriteRequest
  • createDeleteRequest

Both methods support modifying multiple files at the same time with a single request. Currently, you can select multiple files in the app and delete them, but it asks for permission one file at a time. To add the capability to delete multiple files and grant permission all at once, you’ll update Le Memeify to use createDeleteRequest.

Start by adding the following method to FileOperations.kt:

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

Imagine a user wants to remove five files that weren’t created with the app. To overcome the limitation of showing a dialog for each file, instead of calling contentResolver.delete, you can use MediaStore.createDeleteRequest, which allows the user to grant access to all files with a single request.

Now, update deleteMedia inside MainViewModel.kt:

fun deleteMedia(media: List<Media>) {
  if (hasSdkHigherThan(Build.VERSION_CODES.Q) && media.size > 1) {
    val intentSender = 
      FileOperations.deleteMediaBulk(getApplication<Application>(), media)
    _actions.postValue(
        MainAction.ScopedPermissionRequired(
            intentSender, 
            ModificationType.DELETE))
  } else {
    viewModelScope.launch {
      for (item in media) {
        val intentSender = FileOperations.deleteMedia(
                getApplication<Application>(), 
                item)
        if (intentSender != null) {
          _actions.postValue(
              MainAction.ScopedPermissionRequired(
                intentSender, 
                ModificationType.DELETE))
        }
      }
    }
  }
}

The code above will call MediaStore.createDeleteRequest if the device is running Android 11 or higher and there are multiple files selected for removal.

Finally, update onActivityResult inside MainFragment.kt:

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
  super.onActivityResult(requestCode, resultCode, data)
  when (requestCode) {
    REQUEST_PERMISSION_DELETE -> {
      if (resultCode == Activity.RESULT_OK) { 
        val multiSelection = tracker.selection.size() > 1
        if (!multiSelection || !hasSdkHigherThan(Build.VERSION_CODES.Q)) {
          delete()
        }

Only call delete when the conditions above aren’t met. In other words, since you already have permission to remove all the selected files, there’s no need to ask for each file individually.

Build and run the app and delete several images. Be careful and select the worst ones! :]

Deleting several images

Now that you know how to perform bulk operations on files, it’s time to learn how to star a file.

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

Migrating Your App Data

Getting to Know the File Paths API

Refactoring entire applications and libraries that used file paths for various operations can take several months or even years. To make things worse, some native libraries likely no longer have support. To overcome this, Android updated the File API that allows you to continue using Java Files APIs or native C/C++ libraries with scoped storage without the need to make more changes. The file path access is delegated to the MediaStore API, which will handle all the operations.

Understanding Limitations
Android 11 implemented a couple more limitations to respect a user’s private files. With ACTION_OPEN_DOCUMENT and ACTION_OPTION_DOCUMENT_TREE, apps no longer have access to:

  • Root folders of internal storage, SD cards and Downloads/
  • Android/data and Android/obb

Depending on your app’s feature set, you might need to migrate its files/directories. You have two options that cover the most common scenarios.

The first is preserveLegacyExternalStorage. Android 11 introduces this new attribute to the AndroidManifest.xml. It allows your app to have access to your old files directory when the app is updated and until it’s uninstalled. On a fresh install, this flag has no impact on the app:

<application
  android:preserveLegacyExternalStorage="true"
/>

The second is the MediaStore API. You can use the selection arguments from contentResolver.query to get all the media files from your previous directories, use MediaStore.createWriteRequest to move them to a new folder and then use contentResolver.update to update MediaStore. An example of a migration method is shown below:

/**
 * We're using [Environment.getExternalStorageState] dir that has been 	 	 
 * deprecated to migrate files from the old location to the new one.
 */	 	 
@Suppress("deprecation")
suspend fun migrateFiles(context: Context): IntentSender? {
 val images = mutableListOf<Uri>()
 var result: IntentSender? = null
 withContext(Dispatchers.IO) {
   //1
   val externalDir = Environment.getExternalStorageDirectory().path
   val dirSrc = File(externalDir, context.getString(R.string.app_name))
   if (!dirSrc.exists() || dirSrc.listFiles() == null) {
     return@withContext
   }
   //2
   val projection = arrayOf(MediaStore.Images.Media._ID)
   val selection = MediaStore.Images.Media.DATA + " LIKE ? AND " +
         MediaStore.Images.Media.DATA + " NOT LIKE ? "
   val selectionArgs = arrayOf("%" + dirSrc.path + "%", 
                               "%" + dirSrc.path + "/%/%")
   val sortOrder = "${MediaStore.Images.Media.DATE_MODIFIED} DESC"
        context.contentResolver.query(
                  MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                  projection,
                  selection,
                  selectionArgs,
                  sortOrder)?.use { cursor ->
     //3
     while (cursor.moveToNext()) {
        val id = 
        cursor.getLong(cursor.getColumnIndex(
                       MediaStore.Images.Media._ID))
        val uri = 
          ContentUris.withAppendedId(
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI, 
            id)
        images.add(uri)
      }
      cursor.close()
    }
    //4
    val uris = images.filter {
          context.checkUriPermission(it, Binder.getCallingPid(), Binder
            .getCallingUid(), 
             Intent.FLAG_GRANT_WRITE_URI_PERMISSION) != PackageManager
             .PERMISSION_GRANTED
    }
    //5
    if (uris.isNotEmpty()) {
      result = MediaStore.createWriteRequest(context.contentResolver, 
                                             uris).intentSender
      return@withContext
    }
    //6
    val dirDest = File(Environment.DIRECTORY_PICTURES, 
                       context.getString(R.string.app_name))
    //7
    val values = ContentValues().apply {
      put(MediaStore.MediaColumns.RELATIVE_PATH, 
          "$dirDest${File.separator}")
    }
    for (image in images) {
      context.contentResolver.update(image, values, null) 	 
    }
  }
  return result
}

Here’s a step-by-step breakdown of the logic above:

  1. The files were saved in storage/emulated/0/Le Memeify. If this folder is empty, there are no files to migrate.
  2. If the folder contains files to migrate, it’s necessary to get the files’ Uris. To filter only for files in a specific folder, use selection and selectionArgs.
  3. Only the Uri is necessary to find the file, which is stored in a list for later access.
  4. Before starting the update, check if the app has access to those files.
  5. If the app doesn’t have write access, prompt the user by asking for permission. Do this via createWriteRequest, which returns an intentSender that needs to be invoked.
  6. Create a new directory to migrate the files. To obey scoped storage requirements, place it inside DCIM or Pictures. All the images will be moved to Pictures/Le Memeify.
  7. Update the previous path to the new one and call contentResolver to propagate this change.

Since this operation might take a while, you can add a dialog to inform the user there’s an update occurring in the background. For instance:

Migrating data

To test a migration, open an image and select the details to confirm all the files were moved successfully. :]

Restricting Access

With scoped storage, there are more restrictions that might affect other apps requiring higher access to device storage. File explorers and backup apps are an example of this. If they don’t have full access to the disk, they won’t work properly.

Limiting Access to Media Location

When you take a picture, in most cases, you’ll also have the GPS coordinates of your location. Until now, this information was easily accessible to any app by requesting it when loading it from disk.

This is a big vulnerability, since this information can reveal a user’s location. It can be interesting if, for example, you want to see all the countries you visited on a map, but it can be dangerous when someone else uses this information to identify where you live or work.

To overcome this, there’s a new permission on API 29 that you’ll need to declare in AndroidManifest.xml to get access to this information:

<uses-permission android:name=
  "android.permission.ACCESS_MEDIA_LOCATION"/>

Add this permission and then update setImageLocation on DetailsFragment.kt:

@SuppressLint("NewApi")
private fun setImageLocation() {
  val photoUri = MediaStore.setRequireOriginal(image.uri)
  activity?.contentResolver?.openInputStream(photoUri).use { stream ->
    ExifInterface(stream!!).run {
      if (latLong == null) {
        binding.tvLocation.visibility = View.GONE
      } else {
        binding.tvLocation.visibility = View.VISIBLE
        val coordinates = latLong!!.toList()
        binding.tvLocation.text = getString(
                                    R.string.image_location, 
                                    coordinates[0], 
                                    coordinates[1])
      }
    }
  }
}

Since you access files via Uris with scoped storage, you’ll need to call contentResolver?.openInputStream so you can use ExifInterface to retrieve the coordinates from the file.

Build and run. Select an image and click on the information icon. You’ll see different image data: date, storage, GPS coordinates, size and resolution.

Image location

Above is a picture taken in Coimbra, Portugal. :]

Note: As an exercise, reverse geocode these coordinates to get a physical location. Introduction to Google Maps API for Android with Kotlin teaches you how to do this.

Requesting Broader Access

Scoped storage introduces several restrictions to how apps can access files. In this example, after the user grants access, you can create, update, read and remove files. But there are scenarios where this isn’t enough.

Although the next section is out of scope for Le Memeify, the following concepts are important to be aware of.

File Managers and Backup Apps

File managers and backup apps need access to the entire file system, so you’ll need to make more changes. Declare the following permission in AndroidManifest.xml:

<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>

As a security measure, adding this permission isn’t enough; the user still needs to manually grant access to the app. Call:

fun openSettingsAllFilesAccess(activity: AppCompatActivity) {
  val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
  activity.startActivity(intent)
}

This opens the native settings screen in the “All files access” section, which lists all the apps allowed to access the entire file system. To grant this access, the user needs to select both the app and the “Allowed” option.

If your app requires this access, you’ll need to declare it on Google Play.

Limitations

Although you’ll get broader access to the file system with these solutions, there will still be limitations on what your app can access. App-specific storage is sensitive, and for that reason, access to the app’s internal and external files is unavailable. There’s no possibility to grant read/write access over them.

There’s often a feature in file explorers that allows you to check the disk usage and free up space by clearing other apps’ caches. Scoped storage doesn’t allow this to be done in the app. Instead, you need to call:

fun openNativeFileExplorer(activity: AppCompatActivity) {
  val intent = Intent(StorageManager.ACTION_MANAGE_STORAGE)
  activity.startActivity(intent)
}

This launches the native files application. Then call:

fun clearAppsCacheFiles(activity: AppCompatActivity) {
  val intent = Intent(StorageManager.ACTION_CLEAR_APP_CACHE)
  activity.startActivity(intent)
}

This frees up disk space.

Where to Go From Here?

Download the completed project files by clicking the Download Materials button at the top or bottom of the tutorial.

Congratulations! You completed this tutorial. You learned how to take advantage of scoped storage in Android 11 and you improved your app experience!

Now that you’ve mastered scoped storage, why not dive deeper into Android by exploring some of its other features? For instance, the Bubbles tutorial might be a fun challenge. Or consider learning about augmented reality apps in ARCore with Kotlin!

We hope you enjoyed this tutorial. If you have any questions or comments, please join the forum discussion below!

Average Rating

5/5

Add a rating for this content

2 ratings

More like this

Contributors

Comments