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.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

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.