Migrating from get External Storage Public Directory in Android 10 & 11

Subscribe to my newsletter and never miss my upcoming articles

Android 11 introduces breaking changes to working with files on Android. One of these breaking changes includes the deprecation of the following method:

Environment
  .getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)

The Canned Replies Android app used to use this deprecated API. As of Android 11, both importing and exporting features broke. 😬 This article will go through the solution I used to fix these bugs.

Why did Google deprecate get External Storage Public Directory?

In the Android documentation, Google mentions that access to the user's shared and external storage directories is deprecated as of API level 29 for privacy reasons.

Screenshot of deprecation notice

What should I do instead?

The documentation makes a few suggestions as to which alternative API's to use instead. Some suggestions include MediaStore and getExternalFilesDir, which may not always be available.

You may, however, be able to get away with solving this problem with a much simpler approach by using the intent system.

Do users access these files?

Does your app read and write private files to disk, e.g. for caching reasons? Or will the user specifically be involved in the process?

If you are creating features like allowing the user to import or export their data using files, then you can do this with using the intent system. You don't actually need permissions to write to any storage directories and can do so via intents.

Using Intents to read and write files

You can uses the Intent API to read and write files in Android. This API implementation has 2 steps:

  1. Creating your intent to perform an action, e.g. get a file
  2. Handling the system's response to your intent, i.e. success and failure cases

This blog post will go through the example of reading and writing JSON files. This blog post code is written in Kotlin.

Reading files using Intents

To read files using Intents, we can prompt the user to pick files and then we can perform actions with those files.

This example will read a file of any type.

Creating our intent to read a file

First, we need to create an intent to read a file. We can attach this to a user action, for example, the tapping of a button or menu item. Once the user interacts with our view, we can execute requestImportIntent() which is implemented as follows:

private fun requestImportIntent() {
  val pickIntent = Intent(Intent.ACTION_GET_CONTENT)
  pickIntent.type = "*/*"
  startActivityForResult(pickIntent, INTENT_IMPORT_REQUEST)
}

The above code is creating an intent for ACTION_GET_CONTENT which allows the user to pick a file of any type.

Then, we start an activity for request with the request code stored in the INTENT_IMPORT_REQUEST, which is an Int value.

Once this intent activity is created, the user will be prompted to provide a file. This will allow the user to browse their file system and choose the file they want to provide.

Responding to our intent to read the file

To respond to this intent, we need to implement the override method onActivityResult. We can use the following code, which only cares about successful results, i.e. Activity.RESULT_OK:

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
  super.onActivityResult(requestCode, resultCode, data)

  if (resultCode != Activity.RESULT_OK) {
      return
  }

  when (requestCode) {
    INTENT_IMPORT_REQUEST -> handleIntentImport(data)
  }
}

When the requestCode is for the intent we assigned to INTENT_IMPORT_REQUEST, then we should handle it. I've created a method handleIntentImport(data) which takes the nullable Intent data:

private fun handleIntentImport(intent: Intent?) {
  val importedData: Uri = intent?.data ?: return

  try {
    // Do things with imported data
  } catch (e: IOException) {
    e.printStackTrace()
  }
}

The above code allows us to read the data from the intent, which may be null. If it's null, we make an early return out of the function, otherwise we try to perform our actions on the file data.

Writing files using Intents

Similar to reading files with Intents, we can also write files with Intents. The following example will write a JSON file to the user's desired destination. It will allow the user to name the file.

Creating our intent to write a JSON file

We need to create an intent to write to a file. The following code will allow us to prompt the user to choose where they'd like the document we create to be written:

private fun requestExportIntent() {
  val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
  intent.addCategory(Intent.CATEGORY_OPENABLE)
  intent.type = "application/json"
  intent.putExtra(Intent.EXTRA_TITLE, generatedFileNameWithTimestamp)

  startActivityForResult(intent, INTENT_EXPORT_REQUEST)
}

In the above code, we first create an intent for ACTION_CREATE_DOCUMENT and specify that the category is openable. We need to add the category CATEGORY_OPENABLE in order to be able to have full control over the file via the ContentResolver API's.

Specifying the right type is required to ensure that the file has the right file extension. For example, to ensure we have the .json file extension, we need to set the type to application/json.

We can also provide a default file name. In this case, I'm using a generated file name with a timestamp. The user will be able to change this.

Next, the user will choose the location and the file name. Once they successfully create the file, we will be able to respond to the intent.

Responding to our intent to write the file

Similar to our read intent above, we will need to implement the override method onActivityResult. The following code has both the existing read intent code and the new code added:

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
  super.onActivityResult(requestCode, resultCode, data)

  if (resultCode != Activity.RESULT_OK) {
      return
  }

  when (requestCode) {
    INTENT_IMPORT_REQUEST -> handleIntentImport(data) // old

    INTENT_EXPORT_REQUEST -> handleExportIntent(data) // new ✨
  }
}

Given that we get a successful export intent request (i.e. the user has chosen a directory and filename), we can handle that in our handleExportIntent method:

private fun handleExportIntent(intent: Intent?) {
  val uri = intent?.data ?: return

  val outputStream = contentResolver.openOutputStream(uri)
  val exportText = myStringifiedJsonData

  if (exportText == null || outputStream == null) {
    showExportFailureMessage()
    return
  }

  try {
    outputStream.write(exportText.toByteArray())
    outputStream.close()
    showExportSuccessMessage()
  } catch (e: Exception) {
    showExportFailureMessage()
    e.printStackTrace()
  }
}

We can now use the Content Resolver API's to write to this data URI using an output stream.

The example above of myStringifiedJsonData is just a serialized list of POJO's (Plain Old Java Objects). So I serialize the data as JSON, write it to the file, and close the output stream.

Benefits of using intents

  • No permissions required
  • User has full control over the files

No permissions required

What's great about using the intent system is that no extra permissions are required. You do not need access to read and write to storage if you are using intents.

If Google starts cracking down on permissions for Android like they did with the Chrome Web Store, we may find ourselves with more Play Store rejections for requesting permissions we may not need.

User has full control over the files

Unlike when working with getExternalStoragePublicDirectory, your users can choose where the file goes instead of you choosing a default directory. This is more user-friendly as it would allow the user to put it somewhere specific so they can retrieve it later.

Your users will also be able to name the file something sensible, while still allowing your app to provide a default file name.

Be intentional

Be intentional, spiritually and in an Android way. 🙏 In my opinion, the Intent experience is one of the best features of Android. Nowadays, Apple has caught up and offers something similar with iOS.

When using intents, you can use patterns that Android users are already familiar with instead of coming up with creative solutions to read and write to disk.

That said, intents will not work for all use cases, but if users are interacting with these files, it may be a better user experience to use intents rather than to write to specific directories that you choose.

No Comments Yet