Home · Android & Kotlin Tutorials

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.

5/5 2 Ratings

Version

  • Kotlin 1.3, Android 10.0, Android Studio 4.0

Android 10 put a greater emphasis on privacy and security. Android 11 continues this emphasis and gives you many tools to achieve this. One of those tools is scoped storage. This feature impacts your app in a big way if you’re leveraging local file access.

With scoped storage, an app no longer has direct access to all the files in external storage. If an app wants to manipulate a file that it didn’t create, it has to get explicit authorization from the user. These request prompts appear for each file. This provides a new level of control for the user. They can now decide what an app can or cannot do with their files.

Because this can have a potentially heavy impact on existing apps, Android 10 provides an opt-out mechanism. When enabled, it allows an app to work without any of these requirements. The caveat here is that when your app targets API 30 (Android 11), scoped storage starts to be mandatory.

So if your app uses device storage, it’s time to start preparing it for scoped storage.

Note: If you would like to read up on scoped storage in Android 10, take a look at: Scoped Storage in Android 10: Getting Started.

Le Memeify 👌

To learn more about scoped storage, you’ll work on a meme app. You’ll learn a lot and have fun along the way!

The app you’ll work on is called, “Le Memeify”. This app has a built-in interface that allows you to create, edit or delete a meme from any image on your device.

Feel free to share your best memes with your friends. :]

Note: This tutorial assumes that you’re familiar with Android Studio and the basics of Android development. If you’re new to either, read through Beginning Android Development and Kotlin for Android: An Introduction before you continue this tutorial.

Getting Started

Access the tutorial files by clicking on Download Materials at the top or bottom of this tutorial. 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.

Images gallery

This is what you’re going to build! You already have a whole gallery of memes in the app but feel free to download others. Here’s a website you can use: imgflip.

The goal of this article is to show you how to prepare your app for scoped storage.

There are different ways to do this. The first way you’ll see is in the Starter project which uses file paths to access images and stores them in external storage.

The other way is to follow scoped storage requirements. This method requests user access to manipulate files that weren’t created by the app. If granted, it uses URIs to access those files. Also, it stores new images inside the Pictures folder per Google’s recommendation.

Ready to dive in?

Understanding the Project Structure

Open the Starter project in Android Studio and wait for it to synchronize. Take a look at the project directory structure:
Project structure

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

  • model: This is the data object used to represent an image. It contains the file path, creation date, timestamp, 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

In order to access the images on your device, you must grant the storage permission when prompted. Otherwise, you’ll see a toast with the message:

  • “You need to grant media permissions to use the app”.

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

Images gallery and selection

…with different images, of course. :]

Grokking Scoped Storage

Scoped storage addresses several issues with the Android file system. Namely, it aims to better define which files a third-party app can access, where to store files needed or created by the app and what to do with them after the user uninstalls the app.

Before you start building this app, think about the two different types of storage a device has:

  • App-specific: internal storage: Only the app itself can access the files in this directory, known as the “app sandbox”. Because of this, you don’t need to declare the storage permission in the manifest file. Also, no third-party apps can access its content so it’s a good place to store sensitive information. And when the user uninstalls the app, the system deletes the directory.
  • External storage: This is the non-internal space on the user’s disk. It can include an external drive, like an SD Card. The system will not delete data here when the user uninstalls the app. As such, the user can end up with unused files that only take up space.

With these limitations, Android more strictly controls where an app can store files. It can be either of the following:

  • App-specific: external storage: This storage differs from internal storage in that there’s no pre-allocated space for it in the sandbox. The available storage for saving is equal to the disk’s remaining space. But it’s like internal storage in that you don’t need to declare writing permissions and the system will delete all its files when the user uninstalls the app.
  • External storage: This storage includes access to the device gallery through the MediaStore API. To view/edit other files, you need to use the system file picker called through the Storage Access Framework or SAF.

You’ll need to request two permissions for this if the app is targeting Android 9 or Android 10 with legacy mode enabled:

  • READ_EXTERNAL_STORAGE
  • WRITE_EXTERNAL_STORAGE

Newer versions only require READ_EXTERNAL_STORAGE.

Note: Take a look at Android’s Data Storage documentation to learn more.

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.

Changing File IO to URIs

Now that you updated saveImage in DetailsFragment, it’s time to change the remaining logic to use URIs. First, go to DetailsViewModel.kt and update the body ofsaveImage to:

viewModelScope.launch {
  //1
  val type = getApplication<Application>().contentResolver.getType(image.uri)
  val format = Utils.getImageFormat(type!!)
  //2
  FileOperations.saveImage(getApplication(), bitmap, format)
  //3
  _actions.postValue(ImageDetailAction.ImageSaved)
}

Let’s break down this logic into small steps:

  1. Determine the format of the image. First, retrieve the type from Uri and then the corresponding Bitmap.CompressFormat used to compress the bitmap.
  2. Call saveImage, which you just updated, from FileOperations.
  3. Construct ImageDetailAction.ImageSaved and post it so the LiveData will automatically update the UI

Now go to the Utils class and update the contents ofgetImageFormat to:

return when (type) {
  Bitmap.CompressFormat.PNG.name -> {
    Bitmap.CompressFormat.PNG
  }
  Bitmap.CompressFormat.JPEG.name -> {
     Bitmap.CompressFormat.JPEG
  }
  Bitmap.CompressFormat.WEBP.name -> {
     Bitmap.CompressFormat.WEBP
  }
  else -> {
     Bitmap.CompressFormat.JPEG
  }
}

Now, getImageFormat no longer creates a file to get the image compress format from its extension, it uses type instead.

Finally, update saveMeme in DetailsFragment.kt by changing the line viewModel.saveImage(path, createBitmap()) to viewModel.saveImage(image, uri, createBitmap()).

After this update to saveMeme image and uri are passed to saveImage so it can save the image.

Note: On some devices, there might be a small delay between dismissing the keyboard and creating the final bitmap. So, the code above only executes when the keyboard is not visible. Otherwise, the image might become smaller than the original, due to the reduced area.

Compile and run the app. Try it out!

Create new image

Creating a New File in a Specific Location

By default, scoped storage restricts storage of images to the DCIM/ and Pictures/ directories. However, the user can select a specific location to save a file.

To enable this, start by adding a new entry point that will trigger this action.

In menu_details.xml, add the following item:

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

This will add an overflow menu option in DetailsFragment.kt with the text “Save as…”.

Next, go to DetailsFragment.kt and add the following to the when block in onOptionsItemSelected:

R.id.action_save_location -> {
  hideKeyboard(null)
  saveMemeAs()
  true
}

The system will call this when the user selects the Save as option.

Next, declare saveMemeAs in DetailsFragment like this:

private fun saveMemeAs() {
  //1
  val format = Utils.getImageFormat(
      requireActivity().contentResolver.getType(image.uri)!!)
  //2
  val extension = Utils.getImageExtension(format)
  //3
  val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
    addCategory(Intent.CATEGORY_OPENABLE)
    putExtra(Intent.EXTRA_TITLE, "${System.currentTimeMillis()}.$extension")
    type = "image/*"
  }
  //4
  startActivityForResult(intent, REQUEST_SAVE_AS)
}

The code above:

  1. Get the format of the image.
  2. Get the extension of the image.
  3. Call the native file explorer with the intent action, ACTION_CREATE_DOCUMENT and set the extra Intent.EXTRA_TITLE with the file name. When the native file explorer opens, the user will see the defined file name and will have the option to change it.
  4. Call startActivityForResult with the intent and REQUEST_SAVE_AS to find out if the user selected a specific folder or canceled the operation.

Then add the following constant before the class declaration:

private const val REQUEST_SAVE_AS = 400

The requestCode enables the app to identify from which Intent it came back from.

After this, add this use case to onActivityResult right below super.onActivityResult(requestCode, resultCode, data) to save the meme in this specific directory:

when (requestCode) {
  REQUEST_SAVE_AS -> {
  if (resultCode == Activity.RESULT_OK) {
    saveMeme(data?.data)
  }
}

Now if the user selected a folder, the meme will be saved.

Add a new method to FileOperations to save an image directly with a defined URI:

suspend fun saveImage(context: Context, uri: Uri, bitmap: Bitmap, 
    format: Bitmap.CompressFormat) {
  withContext(Dispatchers.IO) {
    context.contentResolver.openOutputStream(uri, "w").use {
      bitmap.compress(format, QUALITY, it)
    }
  }
}

This will write the compressed image to the file.

Now update saveImage in DetailsViewModel inside viewModelScope.launch:

val type = getApplication<Application>().contentResolver.getType(image.uri)
val format = Utils.getImageFormat(type!!)
if (uri == null) {
  FileOperations.saveImage(getApplication(), bitmap, format)
} else {
  FileOperations.saveImage(getApplication(), uri, bitmap, format)
}
_actions.postValue(ImageDetailAction.ImageSaved)

This will verify which saveImage method to call from FileOperations.

Time to build the app and create another meme!

Create image on specific location

Note: Your access to a specific location is temporary. If you try to store a file in the same location after the user restarted the app, you’ll receive a SecurityException. You’ll need to ask for permission again.

Updating an Existing Image

What if the user wants to edit a file created by another application? To do this, you’ll need to ask for write permissions.

Add the following method to FileOperations:

suspend fun updateImage(context: Context, uri: Uri, bitmap: Bitmap,
   format: Bitmap.CompressFormat): IntentSender? {
 var result: IntentSender? = null
 withContext(Dispatchers.IO) {
   try {
     //1
     saveImage(context, uri, bitmap, format)
   } catch (securityException: SecurityException) {
     //2
     if (Utils.hasSdkHigherThan(Build.VERSION_CODES.P)) {
       val recoverableSecurityException =
           securityException as? 
             RecoverableSecurityException ?: throw securityException
       result = recoverableSecurityException.userAction.actionIntent.intentSender
     } else {
       //3
       throw securityException
     }
   }
 }
 return result
}

Here’s what you’re doing in the code above:

  1. Try writing the edited bitmap to the URI you already defined since it’s an existing image.
  2. Ask for the user’s permission to edit the file. Since your app didn’t originally create the file, you’ll get a SecurityException and you’ll need to ask the user for permission to edit the file. This happens if your device has Android 10 (with scoped storage enabled) or later.
  3. If the app is running on an older Android version without scoped storage throw a SecurityException. This can occur if the file is set to read-only.

Next, replace updateImage in DetailsViewModel with the following inside viewModelScope.launch:

//1
val type = getApplication<Application>().contentResolver.getType(image.uri)
val format = Utils.getImageFormat(type!!)
//2
val intentSender = FileOperations.updateImage(
getApplication(), image.uri, bitmap, format)
//3
if (intentSender == null) {
  _actions.postValue(ImageDetailAction.ImageUpdated)
} else {
  _actions.postValue(
    ImageDetailAction.ScopedPermissionRequired(
      intentSender,
      ModificationType.UPDATE
    )
  )
}

In the code above, you:

  1. Get the file format that you’ll use to update the existing image.
  2. Call the previously added method updateImage.
  3. Send an ImageUpdated action to the view to process if the previous call didn’t trigger a SecurityException and the returned result was null. If so, you know that you successfully updated the image.
  4. Construct a ScopedPermissionRequired action with intentSender so the view can request permissions. If intentSender is not null, you know that you need to request permission manually in order to update the file.

Handling the Permission Request

You need to properly handle the permission request. Here’s how you do it.

In DetailsFragment.kt, update the TODO in requestScopedPermission:

startIntentSenderForResult(intentSender, REQUEST_PERMISSION_UPDATE, 
  null, 0, 0, 0, null)

This triggers the process of asking for specific file permissions, you must call startIntentSenderForResult which will launch a new activity for the user to grant access.

Given that the user may deny this request, you need to analyze the intent return state. You’ll need to send REQUEST_PERMISSION_UPDATE along with startIntentSenderForResult. For that, add the following constant to DetailsActivity before the class declaration:

private const val REQUEST_PERMISSION_UPDATE = 200

After this, add this second condition to the when statement in onActivityResult:

when (requestCode) {
  ...
  REQUEST_PERMISSION_UPDATE -> {
    if (resultCode == Activity.RESULT_OK) {
      updateMeme()
    } else {
      Toast.makeText(requireContext(),
        R.string.toast_image_fail, Toast.LENGTH_SHORT).show()
    }
  }
}

This will update the image when the user grants you permission.

Now compile and run the app and create another new meme.

Update image

Deleting an Image

Now, what if the user wants to delete a meme? You can give them that option, too.

The delete operation is similar to the storage process. If you try to delete a file that you don’t own, the system will throw a SecurityException. You’ll need to request permission from the user to do this.

In FileOperations replace the body ofdeleteImage with this:

var result: IntentSender? = null
withContext(Dispatchers.IO) {
  try {
    context.contentResolver.delete(image.uri, "${MediaStore.Images.Media._ID} = ?",
      arrayOf(image.id.toString()))
  } catch (securityException: SecurityException) {
    if (Utils.hasSdkHigherThan(Build.VERSION_CODES.P)) {
      val recoverableSecurityException =
        securityException as? RecoverableSecurityException ?: throw securityException
      result = recoverableSecurityException.userAction.actionIntent.intentSender
    } else {
      throw securityException
    }
  }
}
return result

Now deleteImage will try to delete the image and request permission if you try to delete a file that the app doesn’t own. As you can see, this logic is similar to updateImage. The difference is that you’ll call the delete method from contentResolver to remove the image and all its references from the MediaStore.Images table.

You’ll also need to update the contents of deleteImage in DetailsViewModel within viewModelScope.launch:

val intentSender = FileOperations.deleteImage(getApplication(), image)
if (intentSender == null) {
  _actions.postValue(ImageDetailAction.ImageDeleted)
} else {
  _actions.postValue(
    ImageDetailAction.ScopedPermissionRequired(
      intentSender,
      ModificationType.DELETE
    )
  )
}

This will take care of any missing permissions just as you did before with updateImage:

Then in DetailsFragment.kt modify requestScopedPermission by adding ModificationType.DELETE -> REQUEST_PERMISSION_DELETE as a condition in the when statement. This condition will now account for this new request type.

Add the REQUEST_PERMISSION_DELETE constant before the DetailFragement class declaration:

private const val REQUEST_PERMISSION_DELETE = 100

Once again, since the user can deny permission, it’s important to send a request code.

Add a third condition to the when statement in onActivityResult below the one you added for REQUEST_PERMISSION_UPDATE:

...
REQUEST_PERMISSION_DELETE -> {
  if (resultCode == Activity.RESULT_OK) {
    viewModel.deleteImage(image)
  } else {
    Toast.makeText(requireContext(),
       R.string.toast_deleted_fail, Toast.LENGTH_SHORT).show()
  }
}
...

Call viewModel.deleteImage if the user gives you permission to delete. Otherwise, display a toast warning to inform the user that it’s not possible to remove an image without granting access.

That’s it! You updated the app for scoped storage. Build and run and see what you’ve accomplished.

Delete image

Where to Go From Here?

Congratulations! You completed this tutorial. You prepared your app for scoped storage and you’re ready for Android 11.

Want to know more about protecting users’ privacy? Take a look at this tutorial: Data Privacy for Android. You can also dive into the world of advanced data persistence with this tutorial: Room DB Advanced Data Persistence.

Or if you’re not sure what you want to study or develop next, take a look at this list of all the Android articles on the Ray Wenderlich website: List of Android Articles. Pick one and start from there. :]

You probably made some really cool memes as you worked on Le Memeify. So, why not share some with everyone? Feel free to tag us @rwenderlich with your best creations.

If you have any questions from this tutorial, please post them in the discussion below.

Average Rating

5/5

Add a rating for this content

2 ratings

More like this

Contributors

Comments