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 3 of 4 of this article. Click here to view the first page.

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.