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.
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.