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?
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:
- The files were saved in
storage/emulated/0/Le Memeify
. If this folder is empty, there are no files to migrate. - 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
andselectionArgs
. - Only the
Uri
is necessary to find the file, which is stored in a list for later access. - Before starting the update, check if the app has access to those files.
- If the app doesn’t have write access, prompt the user by asking for permission. Do this via
createWriteRequest
, which returns anintentSender
that needs to be invoked. - 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.
- 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:
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 Uri
s 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.
Above is a picture taken in Coimbra, Portugal. :]
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.