Merge pull request #112215 from syntaxerror247/SAF-support

Android: Implement Storage Access Framework (SAF) support
This commit is contained in:
Rémi Verschelde 2025-12-02 14:15:53 +01:00
commit 12ca45a905
No known key found for this signature in database
GPG key ID: C3336907360768E1
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 Linux, [param show_hidden] is ignored.
[b]Note:[/b] On Android and macOS, native file dialogs have no title. [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 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> </description>
</method> </method>
<method name="file_dialog_with_options_show"> <method name="file_dialog_with_options_show">

View file

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

View file

@ -35,12 +35,13 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.util.Log import android.util.Log
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.annotation.RequiresApi import androidx.core.net.toUri
import org.godotengine.godot.GodotLib 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. * Utility class for managing file selection and file picker activities.
@ -51,7 +52,6 @@ import org.godotengine.godot.io.file.MediaStoreData
internal class FilePicker { internal class FilePicker {
companion object { companion object {
private const val FILE_PICKER_REQUEST = 1000 private const val FILE_PICKER_REQUEST = 1000
private const val FILE_SAVE_REQUEST = 1001
private val TAG = FilePicker::class.java.simpleName private val TAG = FilePicker::class.java.simpleName
// Constants for fileMode values // Constants for fileMode values
@ -69,48 +69,49 @@ internal class FilePicker {
* @param resultCode The result code returned by the activity. * @param resultCode The result code returned by the activity.
* @param data The intent data containing the selected file(s) or directory. * @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?) { 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) { if (resultCode == Activity.RESULT_CANCELED) {
Log.d(TAG, "File picker canceled") Log.d(TAG, "File picker canceled")
GodotLib.filePickerCallback(false, emptyArray()) GodotLib.filePickerCallback(false, emptyArray())
return return
} }
if (resultCode == Activity.RESULT_OK) { if (resultCode == Activity.RESULT_OK) {
val selectedPaths: MutableList<String> = mutableListOf() val selectedFiles: MutableList<String> = mutableListOf()
// Handle multiple file selection.
val clipData = data?.clipData val clipData = data?.clipData
if (clipData != null) { if (clipData != null) {
// Handle multiple file selection.
for (i in 0 until clipData.itemCount) { for (i in 0 until clipData.itemCount) {
val uri = clipData.getItemAt(i).uri val uri = clipData.getItemAt(i).uri
uri?.let { uri?.let {
val filepath = MediaStoreData.getFilePathFromUri(context, uri) try {
if (filepath != null) { context.contentResolver.takePersistableUriPermission(
selectedPaths.add(filepath) it,
} else { Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
Log.d(TAG, "null filepath URI: $it") )
} catch (e: SecurityException) {
Log.d(TAG, "Unable to persist URI: $it", e)
} }
selectedFiles.add(it.toString())
} }
} }
} else { } else {
val uri: Uri? = data?.data val uri: Uri? = data?.data
uri?.let { uri?.let {
val filepath = MediaStoreData.getFilePathFromUri(context, uri) try {
if (filepath != null) { context.contentResolver.takePersistableUriPermission(
selectedPaths.add(filepath) it,
} else { Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
Log.d(TAG, "null filepath URI: $it") )
} } catch (e: SecurityException) {
Log.w(TAG, "Unable to persist URI: $it", e)
if (requestCode == FILE_SAVE_REQUEST) {
DocumentsContract.deleteDocument(context.contentResolver, it)
} }
selectedFiles.add(it.toString())
} }
} }
if (selectedFiles.isNotEmpty()) {
if (selectedPaths.isNotEmpty()) { GodotLib.filePickerCallback(true, selectedFiles.toTypedArray())
GodotLib.filePickerCallback(true, selectedPaths.toTypedArray())
} else { } else {
GodotLib.filePickerCallback(false, emptyArray()) 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 fileMode The mode to operate in, specifying open, save, or directory select.
* @param filters Array of MIME types to filter file selection. * @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>) { fun showFilePicker(context: Context, activity: Activity?, currentDirectory: String, filename: String, fileMode: Int, filters: Array<String>) {
val intent = when (fileMode) { val intent = when (fileMode) {
FILE_MODE_OPEN_DIR -> Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) FILE_MODE_OPEN_DIR -> Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
FILE_MODE_SAVE_FILE -> Intent(Intent.ACTION_CREATE_DOCUMENT) FILE_MODE_SAVE_FILE -> Intent(Intent.ACTION_CREATE_DOCUMENT)
else -> Intent(Intent.ACTION_OPEN_DOCUMENT) else -> Intent(Intent.ACTION_OPEN_DOCUMENT)
} }
val initialDirectory = MediaStoreData.getUriFromDirectoryPath(context, currentDirectory) val initialDirectory = getUriFromDirectoryPath(context, currentDirectory)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && initialDirectory != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && initialDirectory != null) {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, initialDirectory) intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, initialDirectory)
} else { } else {
Log.d(TAG, "Error cannot set initial directory") Log.d(TAG, "Error cannot set initial directory")
@ -157,11 +157,7 @@ internal class FilePicker {
intent.addCategory(Intent.CATEGORY_OPENABLE) intent.addCategory(Intent.CATEGORY_OPENABLE)
} }
intent.putExtra(Intent.EXTRA_LOCAL_ONLY, true) intent.putExtra(Intent.EXTRA_LOCAL_ONLY, true)
if (fileMode == FILE_MODE_SAVE_FILE) { activity?.startActivityForResult(intent, FILE_PICKER_REQUEST)
activity?.startActivityForResult(intent, FILE_SAVE_REQUEST)
} else {
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. // Fallback to a generic MIME type if the input is neither a valid extension nor MIME type.
return "application/octet-stream" 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 package org.godotengine.godot.io
import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import androidx.core.net.toUri
import java.io.File import java.io.File
import org.godotengine.godot.GodotLib import org.godotengine.godot.GodotLib
@ -55,6 +57,11 @@ internal enum class StorageScope {
*/ */
SHARED, SHARED,
/**
* Covers the file access using SAF
*/
SAF,
/** /**
* Everything else.. * Everything else..
*/ */
@ -64,6 +71,7 @@ internal enum class StorageScope {
companion object { companion object {
internal const val ASSETS_PREFIX = "assets://" internal const val ASSETS_PREFIX = "assets://"
internal const val CONTENT_PREFIX = "content://"
} }
private val internalAppDir: String? = context.filesDir.canonicalPath private val internalAppDir: String? = context.filesDir.canonicalPath
@ -93,6 +101,11 @@ internal enum class StorageScope {
return ASSETS 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) var pathFile = File(path)
if (!pathFile.isAbsolute) { if (!pathFile.isAbsolute) {
pathFile = File(GodotLib.getProjectResourceDir(), path) pathFile = File(GodotLib.getProjectResourceDir(), path)

View file

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

View file

@ -30,9 +30,11 @@
package org.godotengine.godot.io.file package org.godotengine.godot.io.file
import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.core.net.toUri
import org.godotengine.godot.error.Error import org.godotengine.godot.error.Error
import org.godotengine.godot.io.StorageScope import org.godotengine.godot.io.StorageScope
import java.io.FileNotFoundException import java.io.FileNotFoundException
@ -64,7 +66,6 @@ internal abstract class DataAccess {
val assetData = AssetData(context, filePath, FileAccessFlags.READ) val assetData = AssetData(context, filePath, FileAccessFlags.READ)
Channels.newInputStream(assetData.readChannel) Channels.newInputStream(assetData.readChannel)
} }
StorageScope.APP -> { StorageScope.APP -> {
val fileData = FileData(filePath, FileAccessFlags.READ) val fileData = FileData(filePath, FileAccessFlags.READ)
Channels.newInputStream(fileData.fileChannel) Channels.newInputStream(fileData.fileChannel)
@ -77,6 +78,10 @@ internal abstract class DataAccess {
null null
} }
} }
StorageScope.SAF -> {
val safData = SAFData(context, filePath, FileAccessFlags.READ)
Channels.newInputStream(safData.fileChannel)
}
StorageScope.UNKNOWN -> null StorageScope.UNKNOWN -> null
} }
@ -91,14 +96,13 @@ internal abstract class DataAccess {
): DataAccess? { ): DataAccess? {
return when (storageScope) { return when (storageScope) {
StorageScope.APP -> FileData(filePath, accessFlag) StorageScope.APP -> FileData(filePath, accessFlag)
StorageScope.ASSETS -> AssetData(context, filePath, accessFlag) StorageScope.ASSETS -> AssetData(context, filePath, accessFlag)
StorageScope.SHARED -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { StorageScope.SHARED -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStoreData(context, filePath, accessFlag) MediaStoreData(context, filePath, accessFlag)
} else { } else {
null null
} }
StorageScope.SAF -> SAFData(context, filePath, accessFlag)
StorageScope.UNKNOWN -> null StorageScope.UNKNOWN -> null
} }
@ -113,6 +117,7 @@ internal abstract class DataAccess {
} else { } else {
false false
} }
StorageScope.SAF -> SAFData.fileExists(context, path)
StorageScope.UNKNOWN -> false StorageScope.UNKNOWN -> false
} }
@ -127,6 +132,7 @@ internal abstract class DataAccess {
} else { } else {
0L 0L
} }
StorageScope.SAF -> SAFData.fileLastModified(context, path)
StorageScope.UNKNOWN -> 0L StorageScope.UNKNOWN -> 0L
} }
@ -136,8 +142,7 @@ internal abstract class DataAccess {
return when(storageScope) { return when(storageScope) {
StorageScope.APP -> FileData.fileLastAccessed(path) StorageScope.APP -> FileData.fileLastAccessed(path)
StorageScope.ASSETS -> AssetData.fileLastAccessed(path) StorageScope.ASSETS -> AssetData.fileLastAccessed(path)
StorageScope.SHARED -> MediaStoreData.fileLastAccessed(context, path) StorageScope.SHARED, StorageScope.SAF, StorageScope.UNKNOWN -> 0L
StorageScope.UNKNOWN -> 0L
} }
} }
@ -145,7 +150,13 @@ internal abstract class DataAccess {
return when(storageScope) { return when(storageScope) {
StorageScope.APP -> FileData.fileSize(path) StorageScope.APP -> FileData.fileSize(path)
StorageScope.ASSETS -> AssetData.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 StorageScope.UNKNOWN -> -1L
} }
} }
@ -159,6 +170,7 @@ internal abstract class DataAccess {
} else { } else {
false false
} }
StorageScope.SAF -> SAFData.delete(context, path)
StorageScope.UNKNOWN -> false StorageScope.UNKNOWN -> false
} }
@ -173,6 +185,7 @@ internal abstract class DataAccess {
} else { } else {
false false
} }
StorageScope.SAF -> SAFData.rename(context, from, to)
StorageScope.UNKNOWN -> false 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} = ? " + private const val SELECTION_BY_PATH = "${MediaStore.Files.FileColumns.DISPLAY_NAME} = ? " +
" AND ${MediaStore.Files.FileColumns.RELATIVE_PATH} = ?" " 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> { private fun getSelectionByPathArguments(path: String): Array<String> {
return arrayOf(getMediaStoreDisplayName(path), getMediaStoreRelativePath(path)) return arrayOf(getMediaStoreDisplayName(path), getMediaStoreRelativePath(path))
} }
@ -212,10 +208,6 @@ internal class MediaStoreData(context: Context, filePath: String, accessFlag: Fi
return dataItem.dateModified.toLong() / 1000L 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 { fun fileSize(context: Context, path: String): Long {
val result = queryByPath(context, path) val result = queryByPath(context, path)
if (result.isEmpty()) { if (result.isEmpty()) {
@ -251,71 +243,6 @@ internal class MediaStoreData(context: Context, filePath: String, accessFlag: Fi
return updated > 0 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 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)
}
}
}