Preparing for Scoped Storage

Android apps targeting Android 11 will be required to use scoped storage to read and write files. In this tutorial, you’ll learn how to migrate your application and also why scoped storage is such a big improvement for the end user. 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.

Running on Android 10

To give developers more time to update their apps to scoped storage, Google is not making this a requirement until Android 11.

While scoped storage is not mandatory on Android 10, you’ll enable it by default if you target API 29. If you aren’t ready for this, you can disable it by setting the value of requestLegacyExternalStorage in AndroidManifest to true:

<manifest ... >
    <application android:requestLegacyExternalStorage="true" ... >
      ...
    </application>
</manifest>

If you’re still targeting older versions, you don’t need to change this value. Pre-Android 10 has scoped storage disabled by default.

It’s important to mention that even if you decide to opt-out of scoped storage, you still might need to declare this new permission in the AndroidManifest. This is certainly the case if your app accesses image location data:

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

Targeting Android 11

On Android 11, scoped storage is mandatory when your app targets API 30. As such, the system will ignore the value of requestLegacyExternalStorage.

Overall, if you want to keep your app updated with the latest features, you’ll need to make all of these changes. Time to dive in!

What’s Going to Change?
Previously, in order to access the files on the external storage, you would ask for these permissions:

  • READ_EXTERNAL_STORAGE: In order to read files.
  • WRITE_EXTERNAL_STORAGE: So you can modify these files.

On Android 11, WRITE_EXTERNAL_STORAGE no longer exists. You’ll need to update your AndroidManifest to limit its usage to API 28, if you’re supporting scoped storage on API 29. Also, remove android:requestLegacyExternalStorage now since you won’t need it anymore.

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
   android:maxSdkVersion="28" />

These new restrictions mean that if you want to modify a file created by another app, you’ll need to explicitly ask the user for permission.

Compile and Target Version
For Le Memeify, you want all the new features of Android 11! So first update the version of your app by opening the build.gradle file and changing the compileSdkVersion from 29 to android-R. Second, set the targetSdkVersion to 30:

buildscript {
 ext {
   ...
   compileSdkVersion = 'android-R'
   targetSdkVersion = 30
   ...
 }
 …
}

Remember, this means you’re now forced to make all the changes necessary for fully supporting scoped storage.

Synchronize your project so these changes can take effect.

If you uninstall the initial version of the app and install this one targeting API 30, you’ll notice that the permission screen changed to include files:

Different permissions text

Updating to Support Scoped Storage

File operations will no longer occur by accessing a file’s path directly. Instead, you’re going to update your code to use the MediaStore API. This will enable you to access files more easily and securely.

If you take a look at how you’re currently saving files, you’ll see that you need to explicitly call MediaScanner to notify your app when some process or app updates a file.

The MediaStore framework does this automatically. This framework is not only easier to implement, it also optimizes file operations. This translates to faster results. It makes use of collections to group files of the same type into the correct folder locations. The categorizations are as follows:

  • Images
  • Videos
  • Audio
  • Downloads
  • Files

Le Memeify uses the Images category. This includes all files located in the DCIM/ or Pictures/ directory. To get an image, you’ll need to use the MediaStore.Images table as you’ll see in the next section.

Loading the Images

Next, take a look at queryImagesOnDevice in the FileOperations class. The first thing you might notice is that it’s accessing constants that have been set as deprecated. The annotation @Suppress(“deprecation”) above the method declaration tells you this. With the new changes for scoped storage, you should no longer use the file path to access an image.

To fix this, change MediaStore.Images.Media.DATA to MediaStore.Images.Media.RELATIVE_PATH.

After this change, you can start to use URIs to access files. Instead of getting the file path from the cursor, retrieve the file’s URI from ContentUris.withAppendId. Because this method adds the id to the end of the path, you can access the ID with this column like cursor.getColumnIndex(MediaStore.Images.Media._ID).

Since you’re now creating an Image object with an URI instead of a file path, you’ll need to update the Image data class to contain this new field by adding val uri: Uri,right above val path: String,. Now the Image class has a uri field.

Finally, you need to change how you’re loading the images. Currently, you’re using Glide to load them from the file path both in ImageAdapter.kt and DetailsFragment.kt. You need to update both files to load from the newly defined uri field instead.

In ImageAdapter.kt, update the line in onBindViewHolder from .load(imageToBind.path) to .load(imageToBind.uri).

DetailsFragment.kt needs the same change in onActivityCreated. Change .load(image.path) to .load(image.uri).

Now when using Glide the Uri is being used to load the image instead of the path in both places. Build and run the app; you’ll see a list of all the images on your device.

Images gallery

Creating a New File

You chose some great memes but now it’s time to create some of your own. You need to make up for the shortage of these on the internet! :]

You can easily create a new one by:

  1. Clicking on an image.
  2. Tapping the smiley face on DetailsFragment.
  3. Adding your meme text to the image.
  4. Saving a copy of that image.

This will call the method saveImage(Context, Bitmap, Bitmap.CompressFormat) in FileOperations. It’s still pre-scoped storage so you’ll update the function first. In DetailsFragment.kt, replace the current implementation of saveImage with:

//1
withContext(Dispatchers.IO) {
  //2
  val collection = 
    MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
  val dirDest = 
    File(Environment.DIRECTORY_PICTURES, context.getString(R.string.app_name))
  val date = System.currentTimeMillis()
  val extension = Utils.getImageExtension(format)
  //3
  val newImage = ContentValues().apply {
    put(MediaStore.Images.Media.DISPLAY_NAME, "$date.$extension")
    put(MediaStore.MediaColumns.MIME_TYPE, "image/$extension")
    put(MediaStore.MediaColumns.DATE_ADDED, date)
    put(MediaStore.MediaColumns.DATE_MODIFIED, date)
    put(MediaStore.MediaColumns.SIZE, bitmap.byteCount)
    put(MediaStore.MediaColumns.WIDTH, bitmap.width)
    put(MediaStore.MediaColumns.HEIGHT, bitmap.height)
    //4
    put(MediaStore.MediaColumns.RELATIVE_PATH, "$dirDest${File.separator}")
    //5
    put(MediaStore.Images.Media.IS_PENDING, 1)
  }
  val newImageUri = context.contentResolver.insert(collection, newImage)
  //6
  context.contentResolver.openOutputStream(newImageUri!!, "w").use {
    bitmap.compress(format, QUALITY, it)
  }
  newImage.clear()
  //7
  newImage.put(MediaStore.Images.Media.IS_PENDING, 0)
  //8
  context.contentResolver.update(newImageUri, newImage, null, null)
}

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

  1. Use Coroutines since this can be a heavy operation. That way this can run on a separate thread. Here, it’s going to run on the IO thread. This is the best way to guarantee that the UI thread is still free for its normal operations and you won’t have an ANR (Activity Not Responding) error during this process.
  2. Save the image to MediaStore.VOLUME_EXTERNAL_PRIMARY. This will make it available to all apps.
  3. Define the image attributes in queryImagesOnDevice so that it’s available when looking for images on disk.
  4. Store the images in Pictures/Le Memeify.
  5. Use this attribute to keep the file private to the app during the creation process. While IS_PENDING is set to 1, no other app can view the file. This prevents some other app or process from corrupting the image during this process.
  6. Define QUALITY as 100% to give the image a similar compression as the original when you write it to disk.
  7. Update IS_PENDING to 0 once you create the image so that other apps can access it.
  8. Call resolver.update with the new value once the operation ends.