diff --git a/Source/Android/app/src/main/AndroidManifest.xml b/Source/Android/app/src/main/AndroidManifest.xml
index 71912da199..4bcd25737c 100644
--- a/Source/Android/app/src/main/AndroidManifest.xml
+++ b/Source/Android/app/src/main/AndroidManifest.xml
@@ -155,6 +155,18 @@
android:resource="@xml/nnf_provider_paths"/>
+
+
+
+
+
+
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/DocumentProvider.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/DocumentProvider.kt
new file mode 100644
index 0000000000..5ce6aaac9a
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/DocumentProvider.kt
@@ -0,0 +1,238 @@
+// Copyright 2023 Dolphin Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+// Partially based on:
+// Skyline
+// SPDX-License-Identifier: MPL-2.0
+// Copyright © 2022 Skyline Team and Contributors (https://github.com/skyline-emu/)
+
+package org.dolphinemu.dolphinemu.features
+
+import android.annotation.TargetApi
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.os.Build
+import android.os.CancellationSignal
+import android.os.ParcelFileDescriptor
+import android.provider.DocumentsContract
+import android.provider.DocumentsProvider
+import android.webkit.MimeTypeMap
+import org.dolphinemu.dolphinemu.R
+import org.dolphinemu.dolphinemu.utils.DirectoryInitialization
+import java.io.File
+import java.io.FileNotFoundException
+
+@TargetApi(Build.VERSION_CODES.N)
+class DocumentProvider : DocumentsProvider() {
+ private var rootDirectory: File? = null
+
+ companion object {
+ private const val ROOT_ID = "root"
+
+ private val DEFAULT_ROOT_PROJECTION = arrayOf(
+ DocumentsContract.Root.COLUMN_ROOT_ID,
+ DocumentsContract.Root.COLUMN_FLAGS,
+ DocumentsContract.Root.COLUMN_ICON,
+ DocumentsContract.Root.COLUMN_TITLE,
+ DocumentsContract.Root.COLUMN_DOCUMENT_ID
+ )
+
+ private val DEFAULT_DOCUMENT_PROJECTION: Array = arrayOf(
+ DocumentsContract.Document.COLUMN_DOCUMENT_ID,
+ DocumentsContract.Document.COLUMN_MIME_TYPE,
+ DocumentsContract.Document.COLUMN_DISPLAY_NAME,
+ DocumentsContract.Document.COLUMN_LAST_MODIFIED,
+ DocumentsContract.Document.COLUMN_FLAGS,
+ DocumentsContract.Document.COLUMN_SIZE
+ )
+ }
+
+ override fun onCreate(): Boolean {
+ rootDirectory = DirectoryInitialization.getUserDirectoryPath(context)
+ return true
+ }
+
+ override fun queryRoots(projection: Array?): Cursor {
+ val result = MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION)
+ rootDirectory = rootDirectory ?: DirectoryInitialization.getUserDirectoryPath(context)
+ rootDirectory ?: return result
+
+ result.newRow().apply {
+ add(DocumentsContract.Root.COLUMN_ROOT_ID, ROOT_ID)
+ add(DocumentsContract.Root.COLUMN_TITLE, context!!.getString(R.string.app_name))
+ add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_dolphin)
+ add(
+ DocumentsContract.Root.COLUMN_FLAGS,
+ DocumentsContract.Root.FLAG_SUPPORTS_CREATE or DocumentsContract.Root.FLAG_SUPPORTS_RECENTS or DocumentsContract.Root.FLAG_SUPPORTS_SEARCH
+ )
+ add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, ROOT_ID)
+ }
+
+ return result
+ }
+
+ override fun queryDocument(documentId: String, projection: Array?): Cursor {
+ val result = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION)
+ rootDirectory = rootDirectory ?: DirectoryInitialization.getUserDirectoryPath(context)
+ rootDirectory ?: return result
+ val file = documentIdToPath(documentId)
+ appendDocument(file, result)
+ return result
+ }
+
+ override fun queryChildDocuments(
+ parentDocumentId: String,
+ projection: Array?,
+ queryArgs: String?
+ ): Cursor {
+ val result = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION)
+ rootDirectory = rootDirectory ?: DirectoryInitialization.getUserDirectoryPath(context)
+ rootDirectory ?: return result
+ val folder = documentIdToPath(parentDocumentId)
+ val files = folder.listFiles()
+ if (files != null) {
+ for (file in files) {
+ appendDocument(file, result)
+ }
+ }
+ return result
+ }
+
+ override fun openDocument(
+ documentId: String,
+ mode: String,
+ signal: CancellationSignal?
+ ): ParcelFileDescriptor {
+ val file = documentIdToPath(documentId)
+ return ParcelFileDescriptor.open(file, ParcelFileDescriptor.parseMode(mode))
+ }
+
+ override fun createDocument(
+ parentDocumentId: String,
+ mimeType: String,
+ displayName: String
+ ): String {
+ val folder = documentIdToPath(parentDocumentId)
+ val file = findFileNameForNewFile(File(folder, displayName))
+ if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
+ file.mkdirs()
+ } else {
+ file.createNewFile()
+ }
+ return pathToDocumentId(file)
+ }
+
+ override fun copyDocument(sourceDocumentId: String, targetParentDocumentId: String): String {
+ val file = documentIdToPath(sourceDocumentId)
+ val target = documentIdToPath(targetParentDocumentId)
+ val copy = copyRecursively(file, File(target, file.name))
+ return pathToDocumentId(copy)
+ }
+
+ override fun removeDocument(documentId: String, parentDocumentId: String) {
+ val file = documentIdToPath(documentId)
+ file.deleteRecursively()
+ }
+
+ override fun moveDocument(
+ sourceDocumentId: String,
+ sourceParentDocumentId: String,
+ targetParentDocumentId: String
+ ): String {
+ val copy = copyDocument(sourceDocumentId, targetParentDocumentId)
+ val file = documentIdToPath(sourceDocumentId)
+ file.delete()
+ return copy
+ }
+
+ override fun renameDocument(documentId: String, displayName: String): String {
+ val file = documentIdToPath(documentId)
+ file.renameTo(findFileNameForNewFile(File(file.parentFile, displayName)))
+ return pathToDocumentId(file)
+ }
+
+ override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean {
+ val file = documentIdToPath(documentId)
+ val folder = documentIdToPath(parentDocumentId)
+ return file.relativeToOrNull(folder) != null
+ }
+
+ private fun appendDocument(file: File, cursor: MatrixCursor) {
+ var flags = 0
+ if (file.isDirectory && file.canWrite()) {
+ flags = DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE
+ } else if (file.canWrite()) {
+ flags = DocumentsContract.Document.FLAG_SUPPORTS_WRITE
+ flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_DELETE
+ flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_REMOVE
+ flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_MOVE
+ flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_COPY
+ flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_RENAME
+ }
+
+ val name = if (file == rootDirectory) {
+ context!!.getString(R.string.app_name)
+ } else {
+ file.name
+ }
+ cursor.newRow().apply {
+ add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, pathToDocumentId(file))
+ add(DocumentsContract.Document.COLUMN_MIME_TYPE, getTypeForFile(file))
+ add(DocumentsContract.Document.COLUMN_DISPLAY_NAME, name)
+ add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, file.lastModified())
+ add(DocumentsContract.Document.COLUMN_FLAGS, flags)
+ add(DocumentsContract.Document.COLUMN_SIZE, file.length())
+ if (file == rootDirectory) {
+ add(DocumentsContract.Document.COLUMN_ICON, R.drawable.ic_dolphin)
+ }
+ }
+ }
+
+ private fun pathToDocumentId(path: File): String {
+ return "$ROOT_ID/${path.toRelativeString(rootDirectory!!)}"
+ }
+
+ private fun documentIdToPath(documentId: String): File {
+ val file = File(rootDirectory, documentId.substring("$ROOT_ID/".lastIndex))
+ if (!file.exists()) {
+ throw FileNotFoundException("File $documentId does not exist.")
+ }
+ return file
+ }
+
+ private fun getTypeForFile(file: File): String {
+ return if (file.isDirectory)
+ DocumentsContract.Document.MIME_TYPE_DIR
+ else
+ MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension)
+ ?: "application/octet-stream"
+ }
+
+ private fun findFileNameForNewFile(file: File): File {
+ var unusedFile = file
+ var i = 1
+ while (unusedFile.exists()) {
+ val pathWithoutExtension = unusedFile.absolutePath.substringBeforeLast('.')
+ val extension = unusedFile.absolutePath.substringAfterLast('.')
+ unusedFile = File("$pathWithoutExtension.$i.$extension")
+ i++
+ }
+ return file
+ }
+
+ private fun copyRecursively(src: File, dst: File): File {
+ val actualDst = findFileNameForNewFile(dst)
+ if (src.isDirectory) {
+ actualDst.mkdirs()
+ val children = src.listFiles()
+ if (children !== null) {
+ for (file in children) {
+ copyRecursively(file, File(actualDst, file.name))
+ }
+ }
+ } else {
+ src.copyTo(actualDst)
+ }
+ return actualDst
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/DirectoryInitialization.java b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/DirectoryInitialization.java
index b1584df1be..0a1acc7097 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/DirectoryInitialization.java
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/DirectoryInitialization.java
@@ -102,17 +102,22 @@ public final class DirectoryInitialization
return new File(externalPath, "dolphin-emu");
}
- private static boolean setDolphinUserDirectory(Context context)
+ @Nullable
+ public static File getUserDirectoryPath(Context context)
{
if (!Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()))
- return false;
+ return null;
isUsingLegacyUserDirectory =
preferLegacyUserDirectory(context) && PermissionsHandler.hasWriteAccess(context);
- File path = isUsingLegacyUserDirectory ?
+ return isUsingLegacyUserDirectory ?
getLegacyUserDirectoryPath() : context.getExternalFilesDir(null);
+ }
+ private static boolean setDolphinUserDirectory(Context context)
+ {
+ File path = DirectoryInitialization.getUserDirectoryPath(context);
if (path == null)
return false;
diff --git a/Source/Android/app/src/main/res/values-v24/bools.xml b/Source/Android/app/src/main/res/values-v24/bools.xml
new file mode 100644
index 0000000000..53ca57cded
--- /dev/null
+++ b/Source/Android/app/src/main/res/values-v24/bools.xml
@@ -0,0 +1,4 @@
+
+
+ true
+
diff --git a/Source/Android/app/src/main/res/values/bools.xml b/Source/Android/app/src/main/res/values/bools.xml
index 758a374559..8ea7e6398a 100644
--- a/Source/Android/app/src/main/res/values/bools.xml
+++ b/Source/Android/app/src/main/res/values/bools.xml
@@ -1,4 +1,5 @@
true
+ false