How to Select Files in Android Easily? Connecting Storage Access Framework and Activity Result API

How to Select Files in Android Easily? Connecting Storage Access Framework and Activity Result API

The relationship status between a developer and the Storage Access Framework (SAF) should be labeled as Complicated. Sure, this solution allows you to access files, but it’s also so annoying it makes you want to shut the laptop down and grab a cup of calming tea. Luckily, there’s a light in this tunnel. SAF relies on Activity result mechanics, so you can connect it with Activity Result API and enjoy a clean, well-organized code. Check out how to do it in a few simple steps.

Note that Storage Access Framework should only be used in specific cases. You want to read photos, videos, or internal storage of the application? There are other solutions that don’t rely on the external document provider pattern and serve this purpose better. Before jumping straight into Storage Access Framework, take a look at the data and file storage overview made by Google itself, and check which access method suits your needs best.

Why Storage Access Framework alone is not enough anymore?

Android Storage Access Framework isn’t really new in terms of storage access and file management – it’s been here since Android 4.4 – but right now it’s gaining more traction than ever. All thanks to the release of Android Q and (not really warmly welcomed) introduction of Scoped Storage requirement in Android applications.

With the enforcement of Scoped Storage, Storage Access Framework seems to be the only reliable solution for accessing device files, although it lacks a lot in terms of developer happiness. The API itself is kind of weird, documentation is missing some important parts, not to mention performance issues and unexpected behavior that are common problems, too. Using SAF surely isn’t the most pleasant part of Android app development.

Like it or not, from all I know Storage Access Framework is here to stay, so it’s best to figure out how to make it easier and more convenient in everyday use. Hacking it together with Activity Result API may be a good way to start.

What is Activity Result API?

This API allows you to ditch the old startActivityForResult() approach, and handle external activity intents (along with their results) in a more straightforward way.

Activity Result API has been introduced in androidx.activity:activity and androidx.fragment:fragment modules.

The idea is pretty simple:

  1. Create an ActivityResultContract implementation with two method overridescreateIntent() that returns an Intent you want to use to open external activity and parseResult() – this one shows s result that you read from the intent returned by the external activity.
  2. Register created contract in ActivityResultRegistry (don’t worry, there are nice convenience methods for Fragment and Activity, too) together with a callback, that will be fired when your external activity delivers its result. This callback will receive the result from parseResult() implementation inside a contract.
  3. Launch ActivityResultLauncher retrieved from registering ActivityResultContract and watch the activity result being handled.

The process of creating a custom ActivityResultContract is simple but to make it even easier Google provides common contract implementations together with the API. This allows you to use TakePicture or RequestPermission contracts out-of-the-box.

Benefits of Activity Result API

The Activity Result API may not seem too interesting – after all, it’s just a wrapper built on top of startActivityForResult() and onActivityResult(). But even this small simplification in handling Android Intents leads to huge benefits in terms of development:

  • Much less boilerplate code in Fragment/Activity – there’s no need to override onActivityResult() anymore, nor to verify the requestCode value in order to check if the result comes from the Activity as you expect. Just launch the contract and handle the result in a callback.
  • Usable outside of Activity – the API needs access to some kind of components with the ActivityResultRegistry but only for registering the launcher. All other components can function without any reference to it, which helps to extract parts of related logic from Activity.
  • Easier to read and understand – it looks much cleaner and more organized than the classic approach. It’s also far easier to understand the way it works. One launcher executes one contract and returns its result to one callback – without any codes or methods in between.

Considering the benefits, Activity Results API is definitely worth a look. You can even observe it slowly becoming the industry standard and pushing out the good old startActivityForResult() approach.

Implementing Storage Access Framework with Activity Results API

I’ve mentioned that the ActivityResultContract class has predefined implementations for most common Intent-related cases. It also implements some of the Storage Access Framework operations like CreateDocument or OpenDocument. You can use these contracts to open the file selection view, built on top of Document Providers.

Storage Access Framwork view

Storage Access Framework view
File picker opened with Storage Access Framework

The default implementations don’t allow too many customization options and enforce parameters, such as a suggested name for document creation. Luckily, there’s nothing holding you back from implementing those from scratch. You can set up different parameters passed to Intent that suit you best.

In my application, I wanted to make a generic implementation that enables easy selection or creation of text files. So, I split my code into:

  • two ResultContract implementations – one for creating and one for selecting files,
  • an interactor class to extract registering and launching contracts from Fragment/Activity to a more generic, reusable place,
  • an interface implemented by Fragment that serves as a callback for handling results.

This construction can serve as an example. It’s also easy to extend in the future.

So, without further ado, let’s get to the code!

Implementing ResultContract for creating and selecting files

You can specify the type of file that you want to select by providing a supported MIME type. For your convenience, I’ve made a simple data class to hold this parameter:

data class SelectFileParams(
    val fileMimeType: String
)

Keep in mind MIME types in Android are case-sensitive, so application/json is valid, but application/JSON is not. In order to avoid issues, you can use normalizeMimeType() to retrieve lowercase MIME type or setTypeAndNormalize() on Intent to make sure your MIME type format is correct.

My implementation of SelectFileResultContract will take this data class as an input parameter and return a Uri pointing to the file user selected.

class SelectFileResultContract : ActivityResultContract<SelectFileParams, Uri?>() {

    override fun createIntent(context: Context, data: SelectFileParams): Intent =
        Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
            addCategory(Intent.CATEGORY_OPENABLE)
            setTypeAndNormalize(data.fileMimeType)
        }

    override fun parseResult(resultCode: Int, intent: Intent?): Uri? = when (resultCode) {
        Activity.RESULT_OK -> intent?.data
        else -> null
    }
}

For creating new files, you may want to specify some more parameters besides MIME type – such as a suggested filename and extension for your newly created file. Remember that users will be able to edit them after opening the Storage Access Framework file picker.

In the case provided filename is already taken, you’ll see an automatically appended number next to the name.

With all these parameters, my data class has more fields:

data class CreateFileParams(
    val fileMimeType: String,
    val fileExtension: String,
    val suggestedName: String
)

Implementation of CreateFileResultContract will take those parameters as input and apply them to the Intent. My output is the same as in the previous example – a Uri object pointing to a created document.

class CreateFileResultContract : ActivityResultContract<CreateFileParams, Uri?>() {

    override fun createIntent(context: Context, data: CreateFileParams): Intent =
        Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
            addCategory(Intent.CATEGORY_OPENABLE)
            setTypeAndNormalize(data.fileMimeType)
            putExtra(Intent.EXTRA_TITLE, "${data.suggestedName}.${data.fileExtension}")
        }

    override fun parseResult(resultCode: Int, intent: Intent?): Uri? = when (resultCode) {
        Activity.RESULT_OK -> intent?.data
        else -> null
    }
}

With your ActivityResultContract classes ready, you can continue with implementing the logic to execute them.

Implementing interactions with Activity Results API

For a start, create an interface or an abstract class. Your Activity or Fragment will implement it to provide access to ActivityResultRegistry and handle launch results.

interface FileSelectionEntryPoint {

    val fileSelectionOwner: Fragment

    fun onFileCreated(fileDescriptor: FileDescriptor?)

    fun onFileSelected(fileDescriptor: FileDescriptor?)
}

You’re going to pass the implementations of this class to your interactor through its constructor. The reason why you must use this kind of structure is a requirement that comes from registerForActivityResult(). ActivityResultContracts can only be registered when their Fragment or Activity is created.

An attempt to register one later will cause the application to crash, so you have to call this method when StorageAccessFrameworkInteractor is created – and that’s the only way to do it.

class StorageAccessFrameworkInteractor(private val fileSelectionEntryPoint: FileSelectionEntryPoint) {

    private val createFileLauncher: ActivityResultLauncher<CreateFileParams> =
        fileSelectionEntryPoint.fileSelectionOwner
            .registerForActivityResult(CreateFileResultContract()) { uri ->
                // TODO: Handle result
            }

    private val selectFileLauncher: ActivityResultLauncher<SelectFileParams> =
        fileSelectionEntryPoint.fileSelectionOwner
            .registerForActivityResult(SelectFileResultContract()) { uri ->
                // TODO: Handle result
            }
}

After you set up the launchers, you can add more methods to handle their result and propagate it to FileSelectionEntryPoint callbacks. In my implementation, I retrieve ContentResolver from Fragment and use it to create a FileDescriptor from Uri, but this part is not necessary. You can just pass a Uri object – depending on your use case and requirements with further processing.

You also have to add methods to launch the ActivityResultLauncher instances. In the end, your implementation may look somewhat close to this:

class StorageAccessFrameworkInteractor(private val fileSelectionEntryPoint: FileSelectionEntryPoint) {

    private val createFileLauncher: ActivityResultLauncher<CreateFileParams> =
        fileSelectionEntryPoint.fileSelectionOwner
            .registerForActivityResult(CreateFileResultContract()) { uri ->
                onFileCreationFinished(uri)
            }

    private val selectFileLauncher: ActivityResultLauncher<SelectFileParams> =
        fileSelectionEntryPoint.fileSelectionOwner
            .registerForActivityResult(SelectFileResultContract()) { uri ->
                onFileSelectionFinished(uri)
            }

    private fun beginCreatingFile(createFileParams: CreateFileParams) =
        createFileLauncher.launch(createFileParams)

    private fun onFileCreationFinished(fileUri: Uri?) {
        val fileDescriptor = fileUri?.let { uri ->
            fileSelectionEntryPoint.fileSelectionOwner
                .requireContext()
                .contentResolver
                .openFileDescriptor(uri, "w")
                ?.fileDescriptor
        }

        fileSelectionEntryPoint.onFileCreated(fileDescriptor)
    }

    private fun beginSelectingFile(selectFileParams: SelectFileParams) =
        selectFileLauncher.launch(selectFileParams)

    private fun onFileSelectionFinished(fileUri: Uri?) {
        val fileDescriptor = fileUri?.let { uri ->
            fileSelectionEntryPoint.fileSelectionOwner
                .requireContext()
                .contentResolver
                .openFileDescriptor(uri, "r")
                ?.fileDescriptor
        }

        fileSelectionEntryPoint.onFileSelected(fileDescriptor)
    }
}

You now have an interactor implementation that will:

  • launch your ActivityResultContract implementations,
  • process the results of the implementations,
  • propagate data back to any Fragment that implements your FileSelectionEntryPoint interface.

Using created implementation in fragments

Minimal implementation of your interface inFragment has to provide its reference in order to call registerForActivityResult() in your interactor. It will also implement methods handling file selection and creation. It may look like this:

class FileTestFragment : Fragment(R.layout.file_test_fragment), FileSelectionEntryPoint {

    override val fileSelectionOwner: Fragment = this
    private val fileSelectionInteractor: StorageAccessFrameworkInteractor =
        StorageAccessFrameworkInteractor(this)

    fun onCreateFileClick(createFileParams: CreateFileParams) =
        fileSelectionInteractor.beginCreatingFile(createFileParams)

    override fun onFileCreated(file: FileDescriptor?) {
        // TODO: Handle file descriptor of created file
    }

    fun onSelectFileClick(selectFileParams: SelectFileParams) =
        fileSelectionInteractor.beginSelectingFile(selectFileParams)

    override fun onFileSelected(fileDescriptor: FileDescriptor?) {
        // TODO: Handle file descriptor of selected file
    }
}

From this point, you can push the functionality of your implementation further and extend its possibilities.

 

Activity Results API increases the productivity of the Android developers and also makes them happier. Together with other components that rely on Activity result mechanics (such as Storage Access Framework), it simplifies the development without losing any features. And it doesn’t even increase the complexity of a codebase. I’d suggest any Android developer give it a shot.

Igor

Igor Kurek

Software Developer at Holdapp, with over 4 years of experience in Android development. Igor enjoys creating and breaking different kinds of software, both at work and after hours. When he’s not looking at the screen, he enjoys all kinds of music-related stuff and modern literature.

Learn more

Project estimation

Let us know what product you want to build and how we can help you.

Why choose us?

Logo Mobile Trends Awards

Mobile Trends Awards 2021

Winning app in
EVERYDAY LIFE

Nagroda Legalnych Bukmacherów

Legal Bookmakers Award 2019

Best Mobile App

Mobile Trends Awards logo

Mobile Trends Awards 2023

Winning app in MCOMMERCE DEVELOPMENT

23

client reviews

Clutch logo