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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Preparing for Scoped Storage
30 mins
- Le Memeify 👌
- Getting Started
- Understanding the Project Structure
- Running Le Memeify
- Grokking Scoped Storage
- Running on Android 10
- Targeting Android 11
- Updating to Support Scoped Storage
- Loading the Images
- Creating a New File
- Changing File IO to URIs
- Creating a New File in a Specific Location
- Updating an Existing Image
- Handling the Permission Request
- Deleting an Image
- Where to Go From Here?
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:
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.
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:
- Clicking on an image.
- Tapping the smiley face on DetailsFragment.
- Adding your meme text to the image.
- 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:
- 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.
- Save the image to
MediaStore.VOLUME_EXTERNAL_PRIMARY
. This will make it available to all apps. - Define the image attributes in
queryImagesOnDevice
so that it’s available when looking for images on disk. - Store the images in Pictures/Le Memeify.
- Use this attribute to keep the file private to the app during the creation process. While
IS_PENDING
is set to1
, no other app can view the file. This prevents some other app or process from corrupting the image during this process. - Define
QUALITY
as 100% to give the image a similar compression as the original when you write it to disk. - Update
IS_PENDING
to0
once you create the image so that other apps can access it. - Call
resolver.update
with the new value once the operation ends.