Android: Implement Storage Access Framework (SAF) support

This commit is contained in:
Anish Kumar 2025-10-30 20:50:48 +05:30
parent ef34c3d534
commit 60d20ab038
8 changed files with 288 additions and 112 deletions

View file

@ -788,6 +788,10 @@
[b]Note:[/b] On Android and Linux, [param show_hidden] is ignored.
[b]Note:[/b] On Android and macOS, native file dialogs have no title.
[b]Note:[/b] On macOS, sandboxed apps will save security-scoped bookmarks to retain access to the opened folders across multiple sessions. Use [method OS.get_granted_permissions] to get a list of saved bookmarks.
[b]Note:[/b] On Android, this method uses the Android Storage Access Framework (SAF).
The file picker returns a URI instead of a filesystem path. This URI can be passed directly to [FileAccess] to perform read/write operations.
When using [constant FILE_DIALOG_MODE_OPEN_DIR], it returns a tree URI that grants full access to the selected directory. File operations inside this directory can be performed by passing a path in the form [code]treeUri#relative/path/to/file[/code] to [FileAccess].
Tree URIs should be saved and reused; they remain valid across app restarts as long as the directory is not moved, renamed, or deleted.
</description>
</method>
<method name="file_dialog_with_options_show">

View file

@ -13,6 +13,7 @@ apply from: "../scripts/publish-module.gradle"
dependencies {
implementation "androidx.fragment:fragment:$versions.fragmentVersion"
implementation 'androidx.documentfile:documentfile:1.1.0'
testImplementation "junit:junit:4.13.2"
}

View file

@ -35,12 +35,13 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.DocumentsContract
import android.util.Log
import android.webkit.MimeTypeMap
import androidx.annotation.RequiresApi
import androidx.core.net.toUri
import org.godotengine.godot.GodotLib
import org.godotengine.godot.io.file.MediaStoreData
import java.io.File
/**
* Utility class for managing file selection and file picker activities.
@ -51,7 +52,6 @@ import org.godotengine.godot.io.file.MediaStoreData
internal class FilePicker {
companion object {
private const val FILE_PICKER_REQUEST = 1000
private const val FILE_SAVE_REQUEST = 1001
private val TAG = FilePicker::class.java.simpleName
// Constants for fileMode values
@ -69,48 +69,49 @@ internal class FilePicker {
* @param resultCode The result code returned by the activity.
* @param data The intent data containing the selected file(s) or directory.
*/
@RequiresApi(Build.VERSION_CODES.Q)
fun handleActivityResult(context: Context, requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == FILE_PICKER_REQUEST || requestCode == FILE_SAVE_REQUEST) {
if (requestCode == FILE_PICKER_REQUEST) {
if (resultCode == Activity.RESULT_CANCELED) {
Log.d(TAG, "File picker canceled")
GodotLib.filePickerCallback(false, emptyArray())
return
}
if (resultCode == Activity.RESULT_OK) {
val selectedPaths: MutableList<String> = mutableListOf()
// Handle multiple file selection.
val selectedFiles: MutableList<String> = mutableListOf()
val clipData = data?.clipData
if (clipData != null) {
// Handle multiple file selection.
for (i in 0 until clipData.itemCount) {
val uri = clipData.getItemAt(i).uri
uri?.let {
val filepath = MediaStoreData.getFilePathFromUri(context, uri)
if (filepath != null) {
selectedPaths.add(filepath)
} else {
Log.d(TAG, "null filepath URI: $it")
try {
context.contentResolver.takePersistableUriPermission(
it,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
} catch (e: SecurityException) {
Log.d(TAG, "Unable to persist URI: $it", e)
}
selectedFiles.add(it.toString())
}
}
} else {
val uri: Uri? = data?.data
uri?.let {
val filepath = MediaStoreData.getFilePathFromUri(context, uri)
if (filepath != null) {
selectedPaths.add(filepath)
} else {
Log.d(TAG, "null filepath URI: $it")
}
if (requestCode == FILE_SAVE_REQUEST) {
DocumentsContract.deleteDocument(context.contentResolver, it)
try {
context.contentResolver.takePersistableUriPermission(
it,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
} catch (e: SecurityException) {
Log.w(TAG, "Unable to persist URI: $it", e)
}
selectedFiles.add(it.toString())
}
}
if (selectedPaths.isNotEmpty()) {
GodotLib.filePickerCallback(true, selectedPaths.toTypedArray())
if (selectedFiles.isNotEmpty()) {
GodotLib.filePickerCallback(true, selectedFiles.toTypedArray())
} else {
GodotLib.filePickerCallback(false, emptyArray())
}
@ -129,15 +130,14 @@ internal class FilePicker {
* @param fileMode The mode to operate in, specifying open, save, or directory select.
* @param filters Array of MIME types to filter file selection.
*/
@RequiresApi(Build.VERSION_CODES.Q)
fun showFilePicker(context: Context, activity: Activity?, currentDirectory: String, filename: String, fileMode: Int, filters: Array<String>) {
val intent = when (fileMode) {
FILE_MODE_OPEN_DIR -> Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
FILE_MODE_SAVE_FILE -> Intent(Intent.ACTION_CREATE_DOCUMENT)
else -> Intent(Intent.ACTION_OPEN_DOCUMENT)
}
val initialDirectory = MediaStoreData.getUriFromDirectoryPath(context, currentDirectory)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && initialDirectory != null) {
val initialDirectory = getUriFromDirectoryPath(context, currentDirectory)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && initialDirectory != null) {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, initialDirectory)
} else {
Log.d(TAG, "Error cannot set initial directory")
@ -157,11 +157,7 @@ internal class FilePicker {
intent.addCategory(Intent.CATEGORY_OPENABLE)
}
intent.putExtra(Intent.EXTRA_LOCAL_ONLY, true)
if (fileMode == FILE_MODE_SAVE_FILE) {
activity?.startActivityForResult(intent, FILE_SAVE_REQUEST)
} else {
activity?.startActivityForResult(intent, FILE_PICKER_REQUEST)
}
activity?.startActivityForResult(intent, FILE_PICKER_REQUEST)
}
/**
@ -201,5 +197,37 @@ internal class FilePicker {
// Fallback to a generic MIME type if the input is neither a valid extension nor MIME type.
return "application/octet-stream"
}
private fun getUriFromDirectoryPath(context: Context, directoryPath: String): Uri? {
if (directoryPath.startsWith("content://")) {
return directoryPath.toUri()
}
if (!directoryExists(directoryPath)) {
return null
}
val externalStorageRoot = Environment.getExternalStorageDirectory().absolutePath
if (directoryPath.startsWith(externalStorageRoot)) {
val relativePath = directoryPath.replaceFirst(externalStorageRoot, "").trim('/')
val uri = Uri.Builder()
.scheme("content")
.authority("com.android.externalstorage.documents")
.appendPath("document")
.appendPath("primary:$relativePath")
.build()
return uri
}
return null
}
private fun directoryExists(path: String): Boolean {
return try {
val file = File(path)
file.isDirectory && file.exists()
} catch (e: SecurityException) {
Log.d(TAG, "Failed to check directoryExists: $path", e)
false
}
}
}
}

View file

@ -30,9 +30,11 @@
package org.godotengine.godot.io
import android.content.ContentResolver
import android.content.Context
import android.os.Build
import android.os.Environment
import androidx.core.net.toUri
import java.io.File
import org.godotengine.godot.GodotLib
@ -55,6 +57,11 @@ internal enum class StorageScope {
*/
SHARED,
/**
* Covers the file access using SAF
*/
SAF,
/**
* Everything else..
*/
@ -64,6 +71,7 @@ internal enum class StorageScope {
companion object {
internal const val ASSETS_PREFIX = "assets://"
internal const val CONTENT_PREFIX = "content://"
}
private val internalAppDir: String? = context.filesDir.canonicalPath
@ -93,6 +101,11 @@ internal enum class StorageScope {
return ASSETS
}
// If it's either content uri for a file, or starts with a tree uri.
if (path.startsWith(CONTENT_PREFIX)) {
return SAF
}
var pathFile = File(path)
if (!pathFile.isAbsolute) {
pathFile = File(GodotLib.getProjectResourceDir(), path)

View file

@ -109,7 +109,7 @@ class DirectoryAccessHandler(context: Context) {
val accessTypeFromStorageScope = when (storageScope) {
StorageScope.ASSETS -> ACCESS_RESOURCES
StorageScope.APP, StorageScope.SHARED -> ACCESS_FILESYSTEM
StorageScope.UNKNOWN -> null
StorageScope.SAF, StorageScope.UNKNOWN -> null
}
if (accessTypeFromStorageScope != null) {

View file

@ -30,9 +30,11 @@
package org.godotengine.godot.io.file
import android.content.ContentResolver
import android.content.Context
import android.os.Build
import android.util.Log
import androidx.core.net.toUri
import org.godotengine.godot.error.Error
import org.godotengine.godot.io.StorageScope
import java.io.FileNotFoundException
@ -64,7 +66,6 @@ internal abstract class DataAccess {
val assetData = AssetData(context, filePath, FileAccessFlags.READ)
Channels.newInputStream(assetData.readChannel)
}
StorageScope.APP -> {
val fileData = FileData(filePath, FileAccessFlags.READ)
Channels.newInputStream(fileData.fileChannel)
@ -77,6 +78,10 @@ internal abstract class DataAccess {
null
}
}
StorageScope.SAF -> {
val safData = SAFData(context, filePath, FileAccessFlags.READ)
Channels.newInputStream(safData.fileChannel)
}
StorageScope.UNKNOWN -> null
}
@ -91,14 +96,13 @@ internal abstract class DataAccess {
): DataAccess? {
return when (storageScope) {
StorageScope.APP -> FileData(filePath, accessFlag)
StorageScope.ASSETS -> AssetData(context, filePath, accessFlag)
StorageScope.SHARED -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStoreData(context, filePath, accessFlag)
} else {
null
}
StorageScope.SAF -> SAFData(context, filePath, accessFlag)
StorageScope.UNKNOWN -> null
}
@ -113,6 +117,7 @@ internal abstract class DataAccess {
} else {
false
}
StorageScope.SAF -> SAFData.fileExists(context, path)
StorageScope.UNKNOWN -> false
}
@ -127,6 +132,7 @@ internal abstract class DataAccess {
} else {
0L
}
StorageScope.SAF -> SAFData.fileLastModified(context, path)
StorageScope.UNKNOWN -> 0L
}
@ -136,8 +142,7 @@ internal abstract class DataAccess {
return when(storageScope) {
StorageScope.APP -> FileData.fileLastAccessed(path)
StorageScope.ASSETS -> AssetData.fileLastAccessed(path)
StorageScope.SHARED -> MediaStoreData.fileLastAccessed(context, path)
StorageScope.UNKNOWN -> 0L
StorageScope.SHARED, StorageScope.SAF, StorageScope.UNKNOWN -> 0L
}
}
@ -145,7 +150,13 @@ internal abstract class DataAccess {
return when(storageScope) {
StorageScope.APP -> FileData.fileSize(path)
StorageScope.ASSETS -> AssetData.fileSize(path)
StorageScope.SHARED -> MediaStoreData.fileSize(context, path)
StorageScope.SHARED -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStoreData.fileSize(context, path)
} else {
-1L
}
StorageScope.SAF -> SAFData.fileSize(context, path)
StorageScope.UNKNOWN -> -1L
}
}
@ -159,6 +170,7 @@ internal abstract class DataAccess {
} else {
false
}
StorageScope.SAF -> SAFData.delete(context, path)
StorageScope.UNKNOWN -> false
}
@ -173,6 +185,7 @@ internal abstract class DataAccess {
} else {
false
}
StorageScope.SAF -> SAFData.rename(context, from, to)
StorageScope.UNKNOWN -> false
}

View file

@ -83,10 +83,6 @@ internal class MediaStoreData(context: Context, filePath: String, accessFlag: Fi
private const val SELECTION_BY_PATH = "${MediaStore.Files.FileColumns.DISPLAY_NAME} = ? " +
" AND ${MediaStore.Files.FileColumns.RELATIVE_PATH} = ?"
private const val AUTHORITY_MEDIA_DOCUMENTS = "com.android.providers.media.documents"
private const val AUTHORITY_EXTERNAL_STORAGE_DOCUMENTS = "com.android.externalstorage.documents"
private const val AUTHORITY_DOWNLOADS_DOCUMENTS = "com.android.providers.downloads.documents"
private fun getSelectionByPathArguments(path: String): Array<String> {
return arrayOf(getMediaStoreDisplayName(path), getMediaStoreRelativePath(path))
}
@ -212,10 +208,6 @@ internal class MediaStoreData(context: Context, filePath: String, accessFlag: Fi
return dataItem.dateModified.toLong() / 1000L
}
fun fileLastAccessed(@Suppress("UNUSED_PARAMETER") context: Context, @Suppress("UNUSED_PARAMETER") path: String): Long {
return 0L
}
fun fileSize(context: Context, path: String): Long {
val result = queryByPath(context, path)
if (result.isEmpty()) {
@ -251,71 +243,6 @@ internal class MediaStoreData(context: Context, filePath: String, accessFlag: Fi
return updated > 0
}
fun getUriFromDirectoryPath(context: Context, directoryPath: String): Uri? {
if (!directoryExists(directoryPath)) {
return null
}
// Check if the path is under external storage.
val externalStorageRoot = Environment.getExternalStorageDirectory().absolutePath
if (directoryPath.startsWith(externalStorageRoot)) {
val relativePath = directoryPath.replaceFirst(externalStorageRoot, "").trim('/')
val uri = Uri.Builder()
.scheme("content")
.authority(AUTHORITY_EXTERNAL_STORAGE_DOCUMENTS)
.appendPath("document")
.appendPath("primary:$relativePath")
.build()
return uri
}
return null
}
fun getFilePathFromUri(context: Context, uri: Uri): String? {
// Converts content uri to filepath.
val id = getIdFromUri(uri) ?: return null
if (uri.authority == AUTHORITY_EXTERNAL_STORAGE_DOCUMENTS) {
val split = id.split(":")
val fileName = split.last()
val relativePath = split.dropLast(1).joinToString("/")
val fullPath = File(Environment.getExternalStorageDirectory(), "$relativePath/$fileName").absolutePath
return fullPath
} else {
val id = id.toLongOrNull() ?: return null
val dataItems = queryById(context, id)
return if (dataItems.isNotEmpty()) {
val dataItem = dataItems[0]
File(Environment.getExternalStorageDirectory(), File(dataItem.relativePath, dataItem.displayName).toString()).absolutePath
} else {
null
}
}
}
private fun getIdFromUri(uri: Uri): String? {
return try {
if (uri.authority == AUTHORITY_EXTERNAL_STORAGE_DOCUMENTS || uri.authority == AUTHORITY_MEDIA_DOCUMENTS || uri.authority == AUTHORITY_DOWNLOADS_DOCUMENTS) {
val documentId = uri.lastPathSegment ?: throw IllegalArgumentException("Invalid URI: $uri")
documentId.substringAfter(":")
} else {
throw IllegalArgumentException("Unsupported URI format: $uri")
}
} catch (e: Exception) {
Log.d(TAG, "Failed to parse ID from URI: $uri", e)
null
}
}
private fun directoryExists(path: String): Boolean {
return try {
val file = File(path)
file.isDirectory && file.exists()
} catch (e: SecurityException) {
Log.d(TAG, "Failed to check directoryExists: $path", e)
false
}
}
}
private val id: Long

View file

@ -0,0 +1,190 @@
/**************************************************************************/
/* SAFData.kt */
/**************************************************************************/
/* This file is part of: */
/* GODOT ENGINE */
/* https://godotengine.org */
/**************************************************************************/
/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */
/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. */
/* */
/* Permission is hereby granted, free of charge, to any person obtaining */
/* a copy of this software and associated documentation files (the */
/* "Software"), to deal in the Software without restriction, including */
/* without limitation the rights to use, copy, modify, merge, publish, */
/* distribute, sublicense, and/or sell copies of the Software, and to */
/* permit persons to whom the Software is furnished to do so, subject to */
/* the following conditions: */
/* */
/* The above copyright notice and this permission notice shall be */
/* included in all copies or substantial portions of the Software. */
/* */
/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */
/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */
/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */
/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */
/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */
/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */
/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */
/**************************************************************************/
package org.godotengine.godot.io.file
import android.content.Context
import android.net.Uri
import android.provider.DocumentsContract
import android.util.Log
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import java.io.FileInputStream
import java.io.FileOutputStream
import java.nio.channels.FileChannel
/**
* Implementation of [DataAccess] which handles file access via a content URI obtained using the Android
* Storage Access Framework (SAF).
*/
internal class SAFData(context: Context, path: String, accessFlag: FileAccessFlags) :
DataAccess.FileChannelDataAccess(path) {
companion object {
private val TAG = SAFData::class.java.simpleName
fun fileExists(context: Context, path: String): Boolean {
return try {
val uri = resolvePath(context, path, FileAccessFlags.READ)
context.contentResolver.query(uri, arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME), null, null, null)
?.use { cursor -> cursor.moveToFirst() } == true
} catch (e: Exception) {
Log.d(TAG, "Error checking file existence", e)
false
}
}
fun fileLastModified(context: Context, path: String): Long {
return try {
val uri = resolvePath(context, path, FileAccessFlags.READ)
val projection = arrayOf(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
context.contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val index = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
if (index != -1) {
return cursor.getLong(index) / 1000L
}
}
}
0L
} catch (e: Exception) {
Log.d(TAG, "Error reading last modified for", e)
0L
}
}
fun fileSize(context: Context, path: String): Long {
return try {
val uri = resolvePath(context, path, FileAccessFlags.READ)
val projection = arrayOf(DocumentsContract.Document.COLUMN_SIZE)
context.contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val index = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE)
if (index != -1) {
return cursor.getLong(index)
}
}
}
-1L
} catch (e: Exception) {
Log.d(TAG, "Error reading file size", e)
-1L
}
}
fun delete(context: Context, path: String): Boolean {
return try {
val uri = resolvePath(context, path, FileAccessFlags.READ)
DocumentsContract.deleteDocument(context.contentResolver, uri)
} catch (e: Exception) {
Log.d(TAG, "Error deleting file", e)
false
}
}
fun rename(context: Context, from: String, to: String): Boolean {
// See https://github.com/godotengine/godot/pull/112215#discussion_r2479311235
return false
}
private fun resolvePath(context: Context, path: String, accessFlag: FileAccessFlags): Uri {
val uri = path.toUri()
val fragment = uri.fragment
if (fragment == null) {
return uri
}
// For directory format: content://treeUri#relative/path/to/file
val treeUri = uri.buildUpon().fragment(null).build()
val relativePath = fragment
val rootDir = DocumentFile.fromTreeUri(context, treeUri)
?: throw IllegalStateException("Unable to resolve tree URI: $treeUri")
val parts = relativePath.split('/')
val filename = parts.last()
val folderParts = parts.dropLast(1)
var current: DocumentFile? = rootDir
val isWriteMode = when (accessFlag) {
FileAccessFlags.WRITE,
FileAccessFlags.READ_WRITE,
FileAccessFlags.WRITE_READ -> true
else -> false
}
for (folder in folderParts) {
var next = current?.findFile(folder)
if (next == null) {
if (isWriteMode) {
next = current?.createDirectory(folder)
?: throw IllegalStateException("Failed to create directory: $folder")
} else {
throw IllegalStateException("Directory not found: $folder")
}
}
current = next
}
var file = current?.findFile(filename)
if (file == null) {
if (isWriteMode) {
file = current?.createFile("*/*", filename)
?: throw IllegalStateException("Failed to create file: $filename")
} else {
throw IllegalStateException("File does not exist: $relativePath")
}
}
return file.uri
}
}
override val fileChannel: FileChannel
init {
val uri = resolvePath(context, path, accessFlag)
val parcelFileDescriptor = context.contentResolver.openFileDescriptor(uri, accessFlag.getMode())
?: throw IllegalStateException("Unable to access file descriptor")
fileChannel = if (accessFlag == FileAccessFlags.READ) {
FileInputStream(parcelFileDescriptor.fileDescriptor).channel
} else {
FileOutputStream(parcelFileDescriptor.fileDescriptor).channel
}
if (accessFlag.shouldTruncate()) {
fileChannel.truncate(0)
}
}
}