X Tutup
Skip to content

Android: Implement Storage Access Framework (SAF) support#112215

Merged
akien-mga merged 1 commit intogodotengine:masterfrom
syntaxerror247:SAF-support
Dec 2, 2025
Merged

Android: Implement Storage Access Framework (SAF) support#112215
akien-mga merged 1 commit intogodotengine:masterfrom
syntaxerror247:SAF-support

Conversation

@syntaxerror247
Copy link
Member

@syntaxerror247 syntaxerror247 commented Oct 30, 2025

Closes godotengine/godot-proposals#12669
Resolves #112136

This PR implements full SAF support:

  • The Android file picker now returns a URI.
  • This URI can be used with FileAccess to perform standard read/write operations.
  • You can also request full access to a directory using FILE_DIALOG_MODE_OPEN_DIR.
  • This would return a tree URI, giving you full access to that directory. You can open/create files inside it directly with paths, without reopening the file picker.
  • This tree URI should be saved to avoid asking for access to that directory every time. It can be reused across device restarts, as long as the directory isn't renamed, moved, or deleted.
  • To work with files inside this directory, use FileAccess as usual, but the path should follow the format treeUri#relative/path/to/file.
  • Finally, you do not need to ask for any file permissions when using this uri method.
Sample code for testing

func _pick_file() -> void:
	var filters = PackedStringArray(["*/*"])
	var current_directory = OS.get_system_dir(OS.SYSTEM_DIR_DESKTOP)
	DisplayServer.file_dialog_show("title", current_directory, "", false, DisplayServer.FILE_DIALOG_MODE_OPEN_FILE, filters, _picker_callback)
	

func _picker_callback(status: bool, selected_uris: PackedStringArray, _selected_filter_index: int):
	$Label.text += str("Status: ",status,"\n")
	$Label.text += str("Selected Uris: ",selected_uris,"\n\n")
	
	var path = selected_uris[0]
	print(FileAccess.file_exists(path))
	print(FileAccess.get_size(path))
	print(FileAccess.get_modified_time(path))
	print(FileAccess.get_file_as_bytes(path))
	print(FileAccess.get_file_as_string(path))
	print(FileAccess.get_md5(path))
	print(FileAccess.get_sha256(path))
	
	print("---Saving file normally---")
	var f4 = FileAccess.open(path, FileAccess.WRITE)
	print(f4.store_string("It seems to be working!"))
	f4.close()
	
	print("---Opening file normally---")
	var f1 = FileAccess.open(path, FileAccess.READ)
	print(f1.get_as_text())
	print(f1.get_buffer(30))
	print(f1.get_path())
	print(f1.get_path_absolute())
	f1.seek(5)
	print(f1.get_position())
	f1.close()
	
	print("---Saving file with password---")
	var f2 = FileAccess.open_encrypted_with_pass(path, FileAccess.WRITE, "password")
	print(f2.store_string("Password is \"password\".\n"))
	print(f2.store_line("This is a new line"))
	f2.close()
	
	print("---Opening file with password---")
	var f3 = FileAccess.open_encrypted_with_pass(path, FileAccess.READ, "password")
	print(f3.get_as_text())
	print(f3.get_buffer(5))
	print(f3.get_path())
	print(f3.get_path_absolute())
	f3.seek(5)
	print(f3.get_position())
	f3.close()


func _get_document_tree_uri() -> void:
	var filters = PackedStringArray(["*/*"])
	var current_directory = OS.get_system_dir(OS.SYSTEM_DIR_DESKTOP)
	DisplayServer.file_dialog_show("title", current_directory, "", false, DisplayServer.FILE_DIALOG_MODE_OPEN_DIR, filters, _picker_callback_directory_tree)

func _picker_callback_directory_tree(_status: bool, selected_uris: PackedStringArray, _selected_filter_index: int):
	var directory_tree_uri = selected_uris[0]
	_picker_callback(true, [str(directory_tree_uri+"#"+"testing_saf.txt")], 0)

Edit: To be able to reuse the URI across app restarts, you need to request persistable permission. See #113367

@syntaxerror247 syntaxerror247 marked this pull request as draft October 30, 2025 20:04
@syntaxerror247 syntaxerror247 force-pushed the SAF-support branch 2 times, most recently from f4c17d8 to ad1e925 Compare November 22, 2025 18:05
@syntaxerror247 syntaxerror247 marked this pull request as ready for review November 22, 2025 18:16
@syntaxerror247
Copy link
Member Author

syntaxerror247 commented Nov 22, 2025

Added support for DocumentTree to allow full access to a directory and do file operations using file paths in this selected directory. For example, when a directory is selected using FILE_DIALOG_MODE_OPEN_DIR, you receive a treeUri for eg content://com.android.externalstorage.documents/tree/primary%3Atestdir. To perform i/o in this directory, you can use FileAccess as usual, but the path must be relative to the selected directory in the format treeUri#relative/path/to/file.

This is now almost ready, only the documentation is left. So, please start reviewing it in the meantime. The sample code is included in the PR description.

@syntaxerror247 syntaxerror247 force-pushed the SAF-support branch 2 times, most recently from ca36f33 to b1a5d72 Compare November 25, 2025 15:37
Copy link
Contributor

@m4gr3d m4gr3d left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code looks good!

Do you think you can turn the sample gdscript code into instrumented tests the way we did for javaclasswrapper?

@m4gr3d
Copy link
Contributor

m4gr3d commented Nov 25, 2025

@syntaxerror247 Once the feature is stable, we should look into updating the Android editor to make use of it (potentially for 4.7).

@syntaxerror247
Copy link
Member Author

Do you think you can turn the sample gdscript code into instrumented tests the way we did for javaclasswrapper?

Yes, I can try.
But there's one limitation: testing this properly would require interacting with the file picker UI, and we can't automate that in instrumented tests. So instead of actually opening the picker, we'd have to fake the SAF responses.

@m4gr3d
Copy link
Contributor

m4gr3d commented Nov 25, 2025

Do you think you can turn the sample gdscript code into instrumented tests the way we did for javaclasswrapper?

Yes, I can try. But there's one limitation: testing this properly would require interacting with the file picker UI, and we can't automate that in instrumented tests. So instead of actually opening the picker, we'd have to fake the SAF responses.

From a google search, it seems to be possible to use espresso to interact with the SAF file picker UI.

@syntaxerror247
Copy link
Member Author

From a google search, it seems to be possible to use espresso to interact with the SAF file picker UI.

Hmm, I thought Espresso was only for app UI testing, it can’t interact with system UI. But I might be wrong since I haven't looked into it much.
I'll check it out. UI Automator's doc mention that it test can interact with system apps.

@syntaxerror247 syntaxerror247 modified the milestones: 4.x, 4.6 Nov 25, 2025
@akien-mga akien-mga merged commit 12ca45a into godotengine:master Dec 2, 2025
20 checks passed
@akien-mga
Copy link
Member

Thanks!

@syntaxerror247 syntaxerror247 deleted the SAF-support branch December 2, 2025 13:53
@procs6166-source
Copy link

Hi everyone, I'm not sure if you could help me with this. I don't know how to implement the SAF system to save the internal MP3 files of the app I created in Godot to my phone.

The MP3 files are located in the res:// folder, and I'd like to save them to a folder on my phone that's easily accessible outside of the app.

@syntaxerror247
Copy link
Member Author

Hi everyone, I'm not sure if you could help me with this. I don't know how to implement the SAF system to save the internal MP3 files of the app I created in Godot to my phone.

The MP3 files are located in the res:// folder, and I'd like to save them to a folder on my phone that's easily accessible outside of the app.

func _pick_file() -> void:
	var filters = PackedStringArray(["*.mp3"])
	var current_directory = OS.get_system_dir(OS.SYSTEM_DIR_MUSIC)
	var filename = "music.mp3"
	DisplayServer.file_dialog_show("title", current_directory, filename, false, DisplayServer.FILE_DIALOG_MODE_SAVE_FILE, filters, _picker_callback)

func _picker_callback(status: bool, selected_uris: PackedStringArray, _selected_filter_index: int):
	var source_file = load("res://music.mp3")
	var destination_path = selected_uris[0]
	var f = FileAccess.open(destination_path, FileAccess.WRITE)
	f.store_buffer(source_file.data)
	f.close()

You can use this sample code to copy the file res://music.mp3 to any path selected by the user.

But please note that questions like this are better suited for the Godot community servers or the official forum: https://godotengine.org/community/

@procs6166-source
Copy link

@syntaxerror247
Thank you so much, it was really helpful, I appreciate it a lot.
I'll keep your last comment in mind.

@gtibo
Copy link
Contributor

gtibo commented Jan 15, 2026

Hello @syntaxerror247
I'm working on the release page for Godot 4.6 and I'm trying adding visuals for every new features.
Would you happen to have a visual showing this feature? (e.g. a screenshot of the android file picker or something)

The card for this PR is inside the Platforms > Android section (https://gtibo.github.io/godot-4.6-release-page/)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

6 participants

X Tutup