Reading and Writing files in Android 10+ Scoped Storage

Android 10 and newer has some great changes to how file system permissions are granted to Apps. This article shows a simple way to read and write files using the new storage APIs from inside Jetpack Compose. The code will open a file browser and allows the user to select the file and path of our target.

One thing that is cool is that we don't require any additional permissions because the file selection is done by the user with a system file picker.

Writing Files

This first block of code is the most important and serves as the glue between the different pieces. We take in success(Uri) and failure handlers to be called on completion. ActivityResultContracts provides a  CreateDocument function to make it super simple for us. I wrap it in a rememberLauncherForActivityResult to handle the Activity result within a Jetpack Compose Composable.

    @Composable
    fun openWritableTextFile(
        onSuccess: (Uri) -> Unit,
        onFailure: () -> Unit
    ): ManagedActivityResultLauncher<String, Uri?> {
    
        return rememberLauncherForActivityResult(
            contract = ActivityResultContracts.CreateDocument("text/plain"),
            onResult = { uri ->
                println("User selected URI: $uri to write to")
                try {
                   // !! with throw if null 
                   // and the try catch will handle it
                    onSuccess(uri!!)
                } catch (e: Exception) {
                    onFailure()
                }
            }
        )
    }

Next we create a writer object from our openWritableTextFile ManagedActivityLauncher and provide it our success(Uri) and failure handlers.  The success handler will pass the Uri and the data into our file writer.

@Composable
fun Exporter() {
    val ctx = LocalContext.current
    
    val data = "Some text"
    val writer = FileHelpers.openWritableTextFile(
   
        // Success handler
        { uri ->
            FileHelpers.writeToFile(uri, data, ctx)
        }, 
        // Failure handler
        {
            ...
        }
    )

    TextButton(
        modifier = Modifier.fillMaxWidth(),
        onClick = {
            writer.launch("file.txt")
        }) {
        Text(text = "Export Data")
    }
...

WriteToFile is mostly standard "write data to a Uri" code. The one gotcha is that the Uri may start with a file:// protocol, or it may start with a content:// protocol. For file:// we can just use the toFile() method. For content:// we need to use the contentResolver to open the stream for us.

fun writeToFile(path: Uri, data: String, ctx: Context) {
        try {
 
            val fos = if (path.toString().startsWith("file")) {
                FileOutputStream(path.toFile())
            } else {
                (ctx as Activity).contentResolver.openOutputStream(path)
            }
            if (fos != null) {
                fos.write(data.toByteArray())
                fos.close()
            } else {
                // user may have pressed back
            }
        } catch (e: IOException) {
            e.printStackTrace()
        }
    }

Reading Files

Reading files is mostly the same except it is a bit more generic because we don't have any data we want to write. We just want a string returned in the success case. ActivityResultContracts  has a OpenDocument function which replaces CreateDocument from the Writer example.

    @Composable
    fun readFile(
        onSuccess: (String) -> Unit,
        onFailure: () -> Unit
    ): ManagedActivityResultLauncher<Array<String>, Uri?> {
        val ctx = LocalContext.current

        return rememberLauncherForActivityResult(
            contract = ActivityResultContracts.OpenDocument(),
            onResult = { uri ->)
                try {
                    onSuccess(readFile(uri!!, ctx))
                } catch (e: Exception) {
                    onFailure()
                }
            }
        )
    }

This next block shows the usage of our file reading component. It displays a button Import Data and will end up logging out "Read file content: $data" if the action succeeds.

@Composable
fun RepoListImporter() {
    val reader = FileHelpers.readFile({ data ->
        // read it
        println("Read file content: $data")
    }, {
        // error opening file
    })

    TextButton(
        modifier = Modifier.fillMaxWidth(),
        onClick = {
            reader.launch(arrayOf("text/plain"))
        },
    ) {
        Text(text = "Import Data")
    }
}

This last block of code is not particularly exciting. I'm sure you can find a nicer way to read data from a Uri. Like in the writing example, you will need to handle the Uri being content:// or file://.

fun readFile(path: Uri, ctx: Context): String {
        println("Reading from $path")
        try {
            val fIn: InputStream? = if (path.toString().startsWith("file")) {
                FileInputStream(path.toFile())
            } else {
                (ctx as Activity).contentResolver.openInputStream(path)
            }
            if (fIn != null) {
                val isr = InputStreamReader(fIn)
                val buffreader = BufferedReader(isr)
                val datax = StringBuffer("")

                var readString: String? = buffreader.readLine()
                while (readString != null) {
                    if (readString.trim().isNotEmpty()) {
                        datax.append(readString).append("\n")
                    }
                    readString = buffreader.readLine()
                }
                return datax.toString().trim()
            } else {
                // user may have pressed back
            }
        } catch (e: IOException) {
            e.printStackTrace()
        }
        return ""
    }

And that's it. This is how I am doing the simple import/export functionality for OSS Tracker.