In answer to your question, a properly implemented StorageAccessFramework will work on all Android versions >= 5.0.
I created a class that fully implements my needs and allows both writing files and reading them. You can specify a standard directory (SAF will open it, but the user can choose any other directory). You can specify useOnlyProvidedDirectoryPath as true if you only need to retrieve data from the specified directory (this can also be useful when working with multiple directories).
SAF allows you to get information about the provided path. You can retrieve and write information to multiple directories at once if needed. On modern OS versions, you will need to grant access for each required directory, but this is easy enough to do.
So, add the following class to the project:
import android.app.Activity
import android.content.Intent
import android.content.UriPermission
import android.net.Uri
import android.os.Build
import android.os.storage.StorageManager
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.FileOutputStream
import java.io.IOException
/**
* Storage Access Framework directory worker.
*
* @param activity Used to create registerForActivityResult and get Context.
*
* @param directoryPath Sets the start directory (Environment.DIRECTORY_* can be used). For
* example, you can use "Music/MyApp". Make sure you have this directory on device to test it,
* otherwise start directory will be undefined.
*
* @param useOnlyProvidedDirectoryPath Use if you want to use directoryPath only. Don't use if you
* have provided directoryPath purely for user experience and there is no need to write data to
* this directory. If it's false and directoryPath isn't found, the last user accepted directory
* will be used. It's **`false`** by default.
*
* @param shouldBeReadable Set to true if you need read access. It's **`true`** by default.
*
* @param shouldBeWriteable Set to true if you need write access. It's **`true`** by default.
*/
class StorageAccessFrameworkDirectory(
private val activity: ComponentActivity,
private val directoryPath: String,
private val useOnlyProvidedDirectoryPath: Boolean = false,
private val shouldBeReadable: Boolean = true,
private val shouldBeWriteable: Boolean = true,
) {
private val showDirectoryPickerActivity = with(activity) {
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
// if user accepted request, and we don't need to useOnlyProvidedDirectoryPath
// or need to use it and user chosen it - take permission for future use
if (uri != null && (
!useOnlyProvidedDirectoryPath ||
isPersistedUriEqualsWithDirectoryPath(uri, directoryPath)
)
) {
var flags = 0
if (shouldBeReadable) {
flags = flags or Intent.FLAG_GRANT_READ_URI_PERMISSION
}
if (shouldBeWriteable) {
flags = flags or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
}
contentResolver.takePersistableUriPermission(uri, flags)
}
}
}
/**
* Shows is usable directory available or not.
*/
fun isUsable(): Boolean {
return activity.getUsableDirectory() != null
}
/**
* Shows system directory picker. Use it if isUsable() is false.
*/
fun showDirectoryPicker() {
val initialUri = activity.getFilePickerInitialUri(directoryPath)
showDirectoryPickerActivity.launch(initialUri)
}
/**
* Writes data from ByteArray to file.
*/
suspend fun writeFile(
fileName: String,
mimeType: String,
data: ByteArray
) = withContext(Dispatchers.IO) {
val uri = activity.getUsableDirectory()
if (!shouldBeWriteable || uri == null) {
return@withContext
}
try {
DocumentFile.fromTreeUri(activity.applicationContext, uri)
?.createFile(mimeType, fileName)
?.let { activity.applicationContext.contentResolver.openOutputStream(it.uri) }
?.buffered()
?.use { it.write(data) }
} catch (e: IOException) {
Log.e("SAF writeFile", e.toString())
}
}
/**
* Allows to continuously write data to file from ByteArrays, provided in continuousWork lambda.
*/
suspend fun writeFileContinuously(
fileName: String,
mimeType: String,
continuousWork: suspend ((ByteArray) -> Unit) -> Unit
) = withContext(Dispatchers.IO) {
val uri = activity.getUsableDirectory()
if (!shouldBeWriteable || uri == null) {
return@withContext
}
try {
DocumentFile.fromTreeUri(activity.applicationContext, uri)
?.createFile(mimeType, fileName)
?.let { activity.applicationContext.contentResolver.openOutputStream(it.uri) }
?.buffered()
?.use { continuousWork { newBytes -> it.write(newBytes) } }
} catch (e: IOException) {
Log.e("SAF writeFileContinuous", e.toString())
}
}
/**
* Returns data from a file in the selected folder, or null if it can't be read.
*/
suspend fun readFile(fileName: String): ByteArray? = withContext(Dispatchers.IO) {
val uri = activity.getUsableDirectory()
if (!shouldBeReadable || uri == null) {
return@withContext null
}
try {
// you can also find in directories with directoryDocumentFile.listFiles()
// println("Usable directory contains:\n" +
// DocumentFile.fromTreeUri(activity.applicationContext, uri)?.listFiles()
// ?.joinToString { "${it.name} (isFile: ${it.isFile}), isDirectory: ${it.isDirectory})" })
with(DocumentFile.fromTreeUri(activity.applicationContext, uri)?.findFile(fileName)) {
if (this == null || !this.canRead()) {
return@withContext null
}
println("found ${this.name}: ${this.uri}")
return@withContext activity.applicationContext.contentResolver
.openInputStream(this.uri)
?.buffered()
?.use { it.readBytes() }
}
} catch (e: IOException) {
Log.e("SAF readFile", e.toString())
return@withContext null
}
}
private fun Context.getUsableDirectory(): Uri? {
val usablePersistedUris = getListOfPersistedUris().filter {
val haveProblemsWithUri = (shouldBeReadable && !it.isReadPermission) ||
(shouldBeWriteable && !it.isWritePermission)
!haveProblemsWithUri
}
val predefinedDirectory = usablePersistedUris.find {
isPersistedUriEqualsWithDirectoryPath(it.uri, directoryPath)
}
if (predefinedDirectory != null || useOnlyProvidedDirectoryPath) {
return predefinedDirectory?.uri
}
return usablePersistedUris.lastOrNull()?.uri
}
companion object {
private fun Context.getFilePickerInitialUri(directoryPath: String): Uri? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
// you can't set directory programmatically on Android 7.1 or lower, so just return null
return null
}
val rootPath = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
// you can't get the root path from the system on old Android, use a hardcoded string
"content://com.android.externalstorage.documents/document/primary%3A"
} else {
// you can get the root path from the system on Android 10+, get it with intent
val intent = (getSystemService(Activity.STORAGE_SERVICE) as StorageManager)
.primaryStorageVolume
.createOpenDocumentTreeIntent()
val systemDefaultPath = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra("android.provider.extra.INITIAL_URI", Uri::class.java)
} else {
@Suppress("Deprecation")
intent.getParcelableExtra("android.provider.extra.INITIAL_URI")
}
systemDefaultPath.toString().replace("/root/", "/document/") + "%3A"
}
return Uri.parse(rootPath + directoryPath)
}
/**
* Checks if directoryPath is in system list of persisted URIs and has read/write access.
*/
fun Context.isDirectoryUsable(
directoryPath: String,
shouldBeReadable: Boolean,
shouldBeWriteable: Boolean,
): Boolean = with(
this.getListOfPersistedUris().find {
isPersistedUriEqualsWithDirectoryPath(it.uri, directoryPath)
},
) {
val haveProblemsWithDirectory = this == null ||
(shouldBeReadable && !this.isReadPermission) ||
(shouldBeWriteable && !this.isWritePermission)
!haveProblemsWithDirectory
}
private fun Context.getListOfPersistedUris(): List<UriPermission> =
contentResolver.persistedUriPermissions
private fun isPersistedUriEqualsWithDirectoryPath(
uri: Uri,
directoryPath: String
): Boolean {
val formattedDirectoryPath = directoryPath.formattedPath()
val uriPath = uri.toString()
.replace("content://com.android.externalstorage.documents/tree/primary%3A", "")
.replace("content://com.android.externalstorage.documents/tree/home%3A", "Documents/")
.replace("%2F", "/")
.formattedPath()
// println("isPersistedUriContainsDirectoryPath:\n" +
// " uri: $uri\n" +
// " directoryPath: $directoryPath" +
// " uriPath: $uriPath\n" +
// " formattedDirectoryPath: $formattedDirectoryPath")
return formattedDirectoryPath == uriPath
}
/**
* Adds / to the beginning if missing, removes it from the end, and replaces with %2F. Examples:
*
* "" -> "%2F"
*
* "/" -> "%2F"
*
* "Documents" -> "%2FDocuments"
*
* "/Documents/" -> "%2FDocuments"
*/
private fun String.formattedPath() = this.removeSuffix("/")
.let { if (it.firstOrNull() != '/') "/$it" else it }.replace("/", "%2F")
}
}
Next, you need to create a class object in your Activity (since the class saves the activity instance, don't save this object in ViewModel, singletons and other activities to avoid memory leaks). Here you can specify a default path and parameters to use the class read-only, write-only, or only on a specific path:
private val safDirectory = StorageAccessFrameworkDirectory(
activity = this,
directoryPath = Environment.DIRECTORY_DOCUMENTS,
)
Next, if safDirectory.isUsable()
is false, you need to show system picker with safDirectory.showDirectoryPicker()
, or if it's true, you can read or write in chosen directory. For example:
if (tableFileFormat == TableFileFormat.XLSX) {
safDirectory.writeFile(
"file_name_without_extension", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
getExcelFile(), // ByteArray with full file data
)
} else {
safDirectory.writeFileContinuously(
"file_name_without_extension", "text/comma-separated-values",
) { writer -> writeCsvFile(writer) } // continuous ByteArray writer function
}
If you need an example of a function that writes data sequentially, here it is:
suspend fun writeCsvFile(writer: suspend (ByteArray) -> Unit) {
writer(
listOf("№", "name")
.joinToString(separator = ",", postfix = "\n").toByteArray(Charsets.UTF_8),
)
names.forEachIndexed { index, name ->
writer(
listOf(index + 1, name)
.joinToString(separator = ",", postfix = "\n").toByteArray(Charsets.UTF_8),
)
}
}
You can extend this class to get a list of files in a folder, save a directory selected by the user (if you want to interact with multiple directories, and the user should be able to select them), and so on.
adb shell
or Android Studio File Explorer. These files/ documents can be used by the user as files, shared, organised in folder, opened with other apps. Don't forget about files. – Oakesit seems like Storage Access Framework is not going to be useful.
Well it is. You only have to let the user choose one directory once and you are done for the life time of your app. – SchreibGet access to the directory using Storage Access Framework (specifically ACTION_OPEN_DOCUMENT_TREE) to get access to a directory, but I couldn't write files into this directory.
Then you did something wrong as after that you can create folders and files in it for the lifetime of your app. – Schreib