Android - accessing external files in different application components

How to correctly handle the file access permissions our application gets after the user chooses a file using the file chooser?

New external files access situation

In new Android versions, some limitations for external files access (even with the correct permissions assigned) are introduced, with such access to be eventually forbidden completely in some future versions.

Instead of accessing the files directly, we have to let our users choose the files using the file chooser. Then, our application gets temporary URI permissions to access the chosen files.

URI permissions vs. application components

Curiously, the obtained permissions are not bound to our application or to the whole process. Instead, they are bound to the application component (the one activity) which received the permissions from the file chooser (to e.g. onActivityResult).

That can cause problems e.g. when we want to process the selected files in a service (which is another application component, different from the original activity). The other component can quickly (or immediately) lose permissions to the URI.

The problem manifests itself by the following error in the background service:

java.lang.SecurityException: Permission Denial: reading com.android.externalstorage.ExternalStorageProvider uri content://com.android.externalstorage.documents/<some_path> from pid=<some_pid>, uid=<some_uid> requires that you obtain access using ACTION_OPEN_DOCUMENT or related APIs

Why is this a problem and why don’t we process the files in the original activity directly? Processing of files in a service is a common case, because we want to do long running tasks independently of:

  • the UI thread (to not block the UI)
  • any activities being open (so that the processing continues even after the user leaves our application UI e.g. to browse another applications)

Investigation

It may be difficult to investigate this problem, because the error in a service happens only after the original activity (the activity which originally received the URI permissions) is destroyed. It looks as if the activity held the permissions (even for the other components of the app, e.g. the service) and destroyed the permissions when the activity itself is destroyed.

So, whether the error occurs or not depends on some application or user behavior timing (determining how long the activity lives).

One more catch: a main/launcher activity is not destroyed immediately when the user presses the Back button. Other activities tend to get destroyed on Back button press. Because of this, it can happen that only refactoring or moving some logic (the file choosing flow) from one activity to another may suddenly cause the background service to throw errors.

Solution

Thankfully, we can grant the permissions to access the chosen files to the service, so that the service can hold them independently of the original activity.

That can be done by carefully preparing the Intent starting the service - two things have to be done:

  • send the chosen file URI(s) in data or clipData of the Intent (do not send them in any extras, because here they wont’be recognized by the permission granting mechanism)
  • add the Intent flags as needed: FLAG_GRANT_READ_URI_PERMISSION, FLAG_GRANT_WRITE_URI_PERMISSION, FLAG_GRANT_PREFIX_URI_PERMISSION

In fact, the Intent received from the file chooser in onActivityResult can serve as a good example of how an Intent providing the permission grants to URI(s) should look like. We can actually only copy the needed fields from this “Intent from file chooser” to our “Intent to start the service”.

The code for the whole flow follows.

Step 1: Run the file chooser

A: File selection - single file allowed

val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.type = "*/*"
val mimetypes = arrayOf("image/png", "application/pdf")
intent.putExtra(Intent.EXTRA_MIME_TYPES, mimetypes)
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, false)
startActivityForResult(intent, REQUEST_CODE_FILES)

B: File selection - multiple files allowed

val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.type = "*/*"
val mimetypes = arrayOf("image/png", "application/pdf")
intent.putExtra(Intent.EXTRA_MIME_TYPES, mimetypes)
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
startActivityForResult(intent, REQUEST_CODE_FILES)

C: Directory selection

val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
startActivityForResult(intent, REQUEST_CODE_FILES)

Step 2: Process the intent from the file chooser, start the service

A: File selection - single file allowed

val intent = Intent(this, ProcessFilesService::class.java)
intent.data = intentFromChooser.data
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) // needed only if we actually want to write, otherwise we can leave this out
startService(intent)

B: File selection - multiple files allowed

val intent = Intent(this, ProcessFilesService::class.java)
intent.data = intentFromChooser.data // will be filled only if the user has chosen a single file
intent.clipData = intentFromChooser.clipData// will be filled only if the user has chosen multiple files
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) // needed only if we actually want to write, otherwise we can leave this out
startService(intent)

C: Directory selection

val intent = Intent(this, ProcessFilesService::class.java)
intent.data = intentFromChooser.data
intent.addFlags(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) // needed only if we actually want to write, otherwise we can leave this out
startService(intent)

Step 3: Access the files in background service

A: File selection - single file allowed

val uri = intent.data!!

val cursor = contentResolver.query(
  uri,
  arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME, DocumentsContract.Document.COLUMN_SIZE, DocumentsContract.Document.COLUMN_MIME_TYPE
  ),
  null,
  null,
  null
)!!

cursor.use {
  cursor.moveToNext()

  val fileName = cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME))
  val size = cursor.getLong(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE))
  val mimeType = cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE))

  val isDirectory = (mimeType == DocumentsContract.Document.MIME_TYPE_DIR)

  contentResolver.openInputStream(uri).use {

  }
}

B: File selection - multiple files allowed

val uris = arrayListOf<Uri>()

val singleFileSelected = intent.data != null
if (singleFileSelected) {
  uris.add(intent.data!!)
}

val multipleFilesSelected = intent.clipData != null
if (multipleFilesSelected) {
  val clipData = intent.clipData!!
  val count = clipData.itemCount
  for (i in 0 until count) {
    uris.add(clipData.getItemAt(i).uri)
  }
}

for (uri in uris) {
  val cursor = contentResolver.query(
    uri,
    arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME, DocumentsContract.Document.COLUMN_SIZE, DocumentsContract.Document.COLUMN_MIME_TYPE
    ),
    null,
    null,
    null
  )!!

  cursor.use {
    cursor.moveToNext()

    val fileName = cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME))
    val size = cursor.getLong(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE))
    val mimeType = cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE))

    val isDirectory = (mimeType == DocumentsContract.Document.MIME_TYPE_DIR)

    contentResolver.openInputStream(uri).use {

    }
  }
}

C: Directory selection

val directoryUri = intent.data!!

val directory = DocumentFile.fromTreeUri(context, directoryUri)!!
val directoryName = directory.name

val childUri = DocumentsContract.buildChildDocumentsUriUsingTree(directoryUri, DocumentsContract.getTreeDocumentId(directoryUri))

val cursor = contentResolver.query(
  childUri,
  arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME, DocumentsContract.Document.COLUMN_SIZE, DocumentsContract.Document.COLUMN_MIME_TYPE),
  null,
  null,
  null
)!!

cursor.use {
  while (cursor.moveToNext()) {
    val documentId = cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID))
    val fileName = cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME))
    val size = cursor.getLong(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE))
    val mimeType = cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE))

    val isDirectory = (mimeType == DocumentsContract.Document.MIME_TYPE_DIR)

    val documentUri = DocumentsContract.buildDocumentUriUsingTree(directoryUri, documentId)

    contentResolver.openInputStream(documentUri).use {

    }
  }
}

About persistable URIs

One might be tempted to solve the problem by making the URI permission persistable, by calling contentResolver.takePersistableUriPermission in the activity which originally obtained the permission.

Then, of course, the background service would work.

However, persistable permissions have some disadvantages:

  • they allow URI access for the application for a longer time than we might need
  • the fact that the application requested something like this is displayed in this screen: App info -> Storage usage
Written on June 11, 2023