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.
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
Scoped Storage Tutorial for Android 11: Deep Dive
30 mins
- Getting Started
- Understanding the Project Structure
- Running Le Memeify
- Introducing Scoped Storage
- What’s New in Android 11
- Bulk Operations
- Starring a File
- Trashing a File
- Migrating Your App Data
- Restricting Access
- Limiting Access to Media Location
- Requesting Broader Access
- File Managers and Backup Apps
- Limitations
- Where to Go From Here?
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:
- 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. - To add media to favorites, first you need to know which files to update. Retrieve the list by filtering all media with the
ID
s of the selected files. - 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.
- Call
requestFavoriteMedia
to add/remove these files from favorites. - 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.
loadFavorites
is 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.
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:
- 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.
- Select the list of media files to send to the trash.
- 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.
- Call
requestTrashMedia
to restore/remove these files. - 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’ Uri
s 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_URI
s 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:
- The
projection
defines the attributes retrieved from theMediaStore
tables. There’s an additionalIS_TRASHED
used internally to select only the elements in the trash. - 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. - Execute the query with all the above parameters defined.
- 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. :]