How to Use the Storage Access Framework in Jetpack Compose ?
Dealing with file storage in modern Android development can be a massive headache. If you try directly accessing file paths on newer Android versions, your app will likely crash due to Scoped Storage restrictions.
No user wants to see a scary WRITE_EXTERNAL_STORAGE prompt just to save a simple text file. Plus, navigating Android’s constantly changing storage rules can leave your codebase messy and non-compliant.
The solution is the Android Storage Access Framework (SAF). By combining SAF with Jetpack Compose and the Activity Result API, you can seamlessly read and save files without requesting a single broad storage permission.
Here is exactly how to build a modern file picker interface in Android.
The Problem with Traditional Android File Storage
In older Android versions, developers could easily request global storage permissions and scan the entire filesystem. Android 10 changed the game by introducing Scoped Storage to protect user privacy.
Now, apps are sandboxed. You can no longer freely read and write files outside your app-specific directories. If your app needs to let users save backups, export documents, or open arbitrary files from their device, direct path manipulation (java.io.File) is officially obsolete.
Why Choose the Android Storage Access Framework (SAF)?
Before looking at the code, you need to understand why SAF is the recommended approach for modern Android app development.
- Zero Runtime Permissions Required: SAF hands control over to a system-managed file picker. The user’s action of selecting a file grants your app temporary access to only that specific file. No dangerous permissions required!
- Total User Control: The user decides where their file goes. They can save it to local device storage or cloud providers like Google Drive and OneDrive.
- Out-of-the-Box UI: Android provides a polished, familiar system document picker. You do not need to build custom file-explorer interfaces from scratch.
Implementing SAF in Jetpack Compose (Step-by-Step)
In Jetpack Compose, we replace the deprecated startActivityForResult pattern with the Activity Result API. This modern API uses predefined contracts to handle common system actions smoothly.
1. Set Up the Activity Result API Launchers
To trigger the system picker, you need rememberLauncherForActivityResult. This Compose function registers a request to start an activity and listens for the system’s callback.
2. Save a File with CreateDocument
To let a user save a new file, we use the CreateDocument contract. You must specify the MIME type (e.g., "text/plain") to tell the system what kind of file you are creating.
When the user selects a location, SAF returns a safe content URI. You then use the ContentResolver to open an openOutputStream and write your data safely.
3. Read a File with OpenDocument
To let the user open an existing file, use the OpenDocument contract. Once the user taps a document, your app receives temporary read access. You can then use openInputStream to extract the file’s contents.
Complete Code Example: File I/O in Jetpack Compose
Here is the full, copy-pasteable reference implementation. This code demonstrates how to save text input to a user-specified location and read it back using Storage Access Framework Jetpack Compose architecture.
package com.devesh.myfileio
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.devesh.myfileio.ui.theme.ui.theme.MyFileIOTheme
class SAFActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MyFileIOTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
SAFScreen()
}
}
}
}
}
}
@Composable
fun SAFScreen() {
val context = LocalContext.current
var input by remember { mutableStateOf("") }
var output by remember { mutableStateOf("No content yet") }
val fileName = "My_New_File.txt"
val contentResolver = context.contentResolver
// 1. Launcher for Creating/Saving a Document
val createFileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/plain")
) { uri ->
uri?.let {
contentResolver.openOutputStream(it)?.use { stream ->
stream.write(input.toByteArray())
}
Toast.makeText(context, "Saved to selected location!", Toast.LENGTH_SHORT).show()
}
}
// 2. Launcher for Reading/Opening a Document
val openFileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let {
val content = contentResolver.openInputStream(it)?.bufferedReader()?.use { reader ->
reader.readText()
}
output = content ?: "Error reading file"
}
}
// 3. The Jetpack Compose UI Layout
Column(Modifier
.padding(20.dp)
.fillMaxSize()) {
TextField(
value = input,
onValueChange = { input = it },
placeholder = { Text("Enter text to save...") },
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(10.dp))
Button(onClick = {
createFileLauncher.launch(fileName)
}, modifier = Modifier.fillMaxWidth()) {
Text("Save to Other Folder")
}
Spacer(Modifier.height(10.dp))
Button(onClick = {
openFileLauncher.launch(arrayOf("text/plain"))
}, modifier = Modifier.fillMaxWidth()) {
Text("Read from Other Folder")
}
Spacer(Modifier.height(10.dp))
Text(text = "File Content: $output", style = MaterialTheme.typography.bodyLarge)
}
}
Pro Tips for Using SAF in Production Apps
When building a commercial app, you need to handle file I/O operations carefully. Keep these two advanced best practices in mind.
Persisting URI Permissions
By default, the read/write access granted to a URI is temporary. If your app restarts, you lose access to that file. If you are building a backup/restore feature, you must request persistable URI permissions:
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
contentResolver.takePersistableUriPermission(uri, takeFlags)
Offloading I/O Tasks with Kotlin Coroutines
In the simple example above, we write small strings directly to streams. However, reading and writing files Android style on the main thread will cause your UI to freeze. Always wrap stream reading/writing in Kotlin Coroutines using the Dispatchers.IO dispatcher:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
// Execute inside a CoroutineScope
withContext(Dispatchers.IO) {
contentResolver.openOutputStream(uri)?.use { stream ->
stream.write(input.toByteArray())
}
}
Frequently Asked Questions (FAQs)
Do I need WRITE_EXTERNAL_STORAGE when using the Storage Access Framework? No. When using SAF, the Android system handles the file writing process. The user explicitly selects a location via the system picker, which safely grants your app temporary, URI-based access without requiring Manifest storage permissions.
How do I keep access to a file after the app restarts?
You need to call takePersistableUriPermission() on the ContentResolver. This saves the access grant, allowing your app to interact with the chosen file even after it is closed from memory.
Can I use SAF to pick multiple files in Jetpack Compose?
Yes! Instead of OpenDocument, you can use ActivityResultContracts.OpenMultipleDocuments(). This allows the user to select several files at once, returning a List of URIs for you to process.
📌 Full Course Playlist https://www.youtube.com/playlist?list=PLO1OrQEU0vHNmD9Xqzs-qXwzzwrDvdhVu
~ ~ THANK YOU FOR READING ~ ~