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?
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:
- Determine the format of the image. First, retrieve the type from Uri and then the corresponding
Bitmap.CompressFormat
used to compress the bitmap. - Call
saveImage
, which you just updated, from FileOperations. - 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.
Compile and run the app. Try it out!
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:
- Get the format of the image.
- Get the extension of the image.
- Call the native file explorer with the intent action,
ACTION_CREATE_DOCUMENT
and set the extraIntent.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. - Call
startActivityForResult
with the intent andREQUEST_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!
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:
- Try writing the edited bitmap to the URI you already defined since it’s an existing image.
- 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. - 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:
- Get the file format that you’ll use to update the existing image.
- Call the previously added method
updateImage
. - Send an
ImageUpdated
action to the view to process if the previous call didn’t trigger aSecurityException
and the returned result was null. If so, you know that you successfully updated the image. - Construct a
ScopedPermissionRequired
action withintentSender
so the view can request permissions. IfintentSender
is not null, you know that you need to request permission manually in order to update the file.