diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml
index 7d3eccc5c..c37559b47 100644
--- a/src/android/app/src/main/AndroidManifest.xml
+++ b/src/android/app/src/main/AndroidManifest.xml
@@ -74,6 +74,18 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/nnf_provider_paths" />
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt
index 273d4951a..a0c5c5c25 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt
@@ -8,9 +8,12 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import org.yuzu.yuzu_emu.model.GameDatabase
-import org.yuzu.yuzu_emu.utils.DirectoryInitialization.start
+import org.yuzu.yuzu_emu.utils.DirectoryInitialization
import org.yuzu.yuzu_emu.utils.DocumentsTree
import org.yuzu.yuzu_emu.utils.GpuDriverHelper
+import java.io.File
+
+fun Context.getPublicFilesDir() : File = getExternalFilesDir(null) ?: filesDir
class YuzuApplication : Application() {
private fun createNotificationChannel() {
@@ -36,7 +39,7 @@ class YuzuApplication : Application() {
super.onCreate()
application = this
documentsTree = DocumentsTree()
- start(applicationContext)
+ DirectoryInitialization.start(applicationContext)
GpuDriverHelper.initializeDriverParameters(applicationContext)
NativeLibrary.LogDeviceInfo()
@@ -50,10 +53,10 @@ class YuzuApplication : Application() {
@JvmField
var documentsTree: DocumentsTree? = null
- private var application: YuzuApplication? = null
+ lateinit var application: YuzuApplication
@JvmStatic
val appContext: Context
- get() = application!!.applicationContext
+ get() = application.applicationContext
}
}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt
new file mode 100644
index 000000000..e6e9a6fe8
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt
@@ -0,0 +1,300 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+// SPDX-License-Identifier: MPL-2.0
+// Copyright © 2023 Skyline Team and Contributors (https://github.com/skyline-emu/)
+
+package org.yuzu.yuzu_emu.features
+
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.os.CancellationSignal
+import android.os.ParcelFileDescriptor
+import android.provider.DocumentsContract
+import android.provider.DocumentsProvider
+import android.webkit.MimeTypeMap
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.getPublicFilesDir
+import java.io.*
+
+class DocumentProvider : DocumentsProvider() {
+ private val baseDirectory: File
+ get() = File(YuzuApplication.application.getPublicFilesDir().canonicalPath)
+
+ companion object {
+ private val DEFAULT_ROOT_PROJECTION: Array = arrayOf(
+ DocumentsContract.Root.COLUMN_ROOT_ID,
+ DocumentsContract.Root.COLUMN_MIME_TYPES,
+ DocumentsContract.Root.COLUMN_FLAGS,
+ DocumentsContract.Root.COLUMN_ICON,
+ DocumentsContract.Root.COLUMN_TITLE,
+ DocumentsContract.Root.COLUMN_SUMMARY,
+ DocumentsContract.Root.COLUMN_DOCUMENT_ID,
+ DocumentsContract.Root.COLUMN_AVAILABLE_BYTES
+ )
+
+ 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
+ )
+
+ const val ROOT_ID: String = "root"
+ }
+
+ override fun onCreate(): Boolean {
+ return true
+ }
+
+ /**
+ * @return The [File] that corresponds to the document ID supplied by [getDocumentId]
+ */
+ private fun getFile(documentId: String): File {
+ if (documentId.startsWith(ROOT_ID)) {
+ val file = baseDirectory.resolve(documentId.drop(ROOT_ID.length + 1))
+ if (!file.exists()) throw FileNotFoundException("${file.absolutePath} ($documentId) not found")
+ return file
+ } else {
+ throw FileNotFoundException("'$documentId' is not in any known root")
+ }
+ }
+
+ /**
+ * @return A unique ID for the provided [File]
+ */
+ private fun getDocumentId(file: File): String {
+ return "$ROOT_ID/${file.toRelativeString(baseDirectory)}"
+ }
+
+ override fun queryRoots(projection: Array?): Cursor {
+ val cursor = MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION)
+
+ cursor.newRow().apply {
+ add(DocumentsContract.Root.COLUMN_ROOT_ID, ROOT_ID)
+ add(DocumentsContract.Root.COLUMN_SUMMARY, null)
+ add(
+ DocumentsContract.Root.COLUMN_FLAGS,
+ DocumentsContract.Root.FLAG_SUPPORTS_CREATE or DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD
+ )
+ add(DocumentsContract.Root.COLUMN_TITLE, context!!.getString(R.string.app_name))
+ add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, getDocumentId(baseDirectory))
+ add(DocumentsContract.Root.COLUMN_MIME_TYPES, "*/*")
+ add(DocumentsContract.Root.COLUMN_AVAILABLE_BYTES, baseDirectory.freeSpace)
+ add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_yuzu)
+ }
+
+ return cursor
+ }
+
+ override fun queryDocument(documentId: String?, projection: Array?): Cursor {
+ val cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION)
+ return includeFile(cursor, documentId, null)
+ }
+
+ override fun isChildDocument(parentDocumentId: String?, documentId: String?): Boolean {
+ return documentId?.startsWith(parentDocumentId!!) ?: false
+ }
+
+ /**
+ * @return A new [File] with a unique name based off the supplied [name], not conflicting with any existing file
+ */
+ private fun File.resolveWithoutConflict(name: String): File {
+ var file = resolve(name)
+ if (file.exists()) {
+ var noConflictId =
+ 1 // Makes sure two files don't have the same name by adding a number to the end
+ val extension = name.substringAfterLast('.')
+ val baseName = name.substringBeforeLast('.')
+ while (file.exists())
+ file = resolve("$baseName (${noConflictId++}).$extension")
+ }
+ return file
+ }
+
+ override fun createDocument(
+ parentDocumentId: String?,
+ mimeType: String?,
+ displayName: String
+ ): String {
+ val parentFile = getFile(parentDocumentId!!)
+ val newFile = parentFile.resolveWithoutConflict(displayName)
+
+ try {
+ if (DocumentsContract.Document.MIME_TYPE_DIR == mimeType) {
+ if (!newFile.mkdir())
+ throw IOException("Failed to create directory")
+ } else {
+ if (!newFile.createNewFile())
+ throw IOException("Failed to create file")
+ }
+ } catch (e: IOException) {
+ throw FileNotFoundException("Couldn't create document '${newFile.path}': ${e.message}")
+ }
+
+ return getDocumentId(newFile)
+ }
+
+ override fun deleteDocument(documentId: String?) {
+ val file = getFile(documentId!!)
+ if (!file.delete())
+ throw FileNotFoundException("Couldn't delete document with ID '$documentId'")
+ }
+
+ override fun removeDocument(documentId: String, parentDocumentId: String?) {
+ val parent = getFile(parentDocumentId!!)
+ val file = getFile(documentId)
+
+ if (parent == file || file.parentFile == null || file.parentFile!! == parent) {
+ if (!file.delete())
+ throw FileNotFoundException("Couldn't delete document with ID '$documentId'")
+ } else {
+ throw FileNotFoundException("Couldn't delete document with ID '$documentId'")
+ }
+ }
+
+ override fun renameDocument(documentId: String?, displayName: String?): String {
+ if (displayName == null)
+ throw FileNotFoundException("Couldn't rename document '$documentId' as the new name is null")
+
+ val sourceFile = getFile(documentId!!)
+ val sourceParentFile = sourceFile.parentFile
+ ?: throw FileNotFoundException("Couldn't rename document '$documentId' as it has no parent")
+ val destFile = sourceParentFile.resolve(displayName)
+
+ try {
+ if (!sourceFile.renameTo(destFile))
+ throw FileNotFoundException("Couldn't rename document from '${sourceFile.name}' to '${destFile.name}'")
+ } catch (e: Exception) {
+ throw FileNotFoundException("Couldn't rename document from '${sourceFile.name}' to '${destFile.name}': ${e.message}")
+ }
+
+ return getDocumentId(destFile)
+ }
+
+ private fun copyDocument(
+ sourceDocumentId: String, sourceParentDocumentId: String,
+ targetParentDocumentId: String?
+ ): String {
+ if (!isChildDocument(sourceParentDocumentId, sourceDocumentId))
+ throw FileNotFoundException("Couldn't copy document '$sourceDocumentId' as its parent is not '$sourceParentDocumentId'")
+
+ return copyDocument(sourceDocumentId, targetParentDocumentId)
+ }
+
+ override fun copyDocument(sourceDocumentId: String, targetParentDocumentId: String?): String {
+ val parent = getFile(targetParentDocumentId!!)
+ val oldFile = getFile(sourceDocumentId)
+ val newFile = parent.resolveWithoutConflict(oldFile.name)
+
+ try {
+ if (!(newFile.createNewFile() && newFile.setWritable(true) && newFile.setReadable(true)))
+ throw IOException("Couldn't create new file")
+
+ FileInputStream(oldFile).use { inStream ->
+ FileOutputStream(newFile).use { outStream ->
+ inStream.copyTo(outStream)
+ }
+ }
+ } catch (e: IOException) {
+ throw FileNotFoundException("Couldn't copy document '$sourceDocumentId': ${e.message}")
+ }
+
+ return getDocumentId(newFile)
+ }
+
+ override fun moveDocument(
+ sourceDocumentId: String, sourceParentDocumentId: String?,
+ targetParentDocumentId: String?
+ ): String {
+ try {
+ val newDocumentId = copyDocument(
+ sourceDocumentId, sourceParentDocumentId!!,
+ targetParentDocumentId
+ )
+ removeDocument(sourceDocumentId, sourceParentDocumentId)
+ return newDocumentId
+ } catch (e: FileNotFoundException) {
+ throw FileNotFoundException("Couldn't move document '$sourceDocumentId'")
+ }
+ }
+
+ private fun includeFile(cursor: MatrixCursor, documentId: String?, file: File?): MatrixCursor {
+ val localDocumentId = documentId ?: file?.let { getDocumentId(it) }
+ val localFile = file ?: getFile(documentId!!)
+
+ var flags = 0
+ if (localFile.isDirectory && localFile.canWrite()) {
+ flags = DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE
+ } else if (localFile.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
+ }
+
+ cursor.newRow().apply {
+ add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, localDocumentId)
+ add(
+ DocumentsContract.Document.COLUMN_DISPLAY_NAME,
+ if (localFile == baseDirectory) context!!.getString(R.string.app_name) else localFile.name
+ )
+ add(DocumentsContract.Document.COLUMN_SIZE, localFile.length())
+ add(DocumentsContract.Document.COLUMN_MIME_TYPE, getTypeForFile(localFile))
+ add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, localFile.lastModified())
+ add(DocumentsContract.Document.COLUMN_FLAGS, flags)
+ if (localFile == baseDirectory)
+ add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_yuzu)
+ }
+
+ return cursor
+ }
+
+ private fun getTypeForFile(file: File): Any {
+ return if (file.isDirectory)
+ DocumentsContract.Document.MIME_TYPE_DIR
+ else
+ getTypeForName(file.name)
+ }
+
+ private fun getTypeForName(name: String): Any {
+ val lastDot = name.lastIndexOf('.')
+ if (lastDot >= 0) {
+ val extension = name.substring(lastDot + 1)
+ val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
+ if (mime != null)
+ return mime
+ }
+ return "application/octect-stream"
+ }
+
+ override fun queryChildDocuments(
+ parentDocumentId: String?,
+ projection: Array?,
+ sortOrder: String?
+ ): Cursor {
+ var cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION)
+
+ val parent = getFile(parentDocumentId!!)
+ for (file in parent.listFiles()!!)
+ cursor = includeFile(cursor, null, file)
+
+ return cursor
+ }
+
+ override fun openDocument(
+ documentId: String?,
+ mode: String?,
+ signal: CancellationSignal?
+ ): ParcelFileDescriptor {
+ val file = documentId?.let { getFile(it) }
+ val accessMode = ParcelFileDescriptor.parseMode(mode)
+ return ParcelFileDescriptor.open(file, accessMode)
+ }
+}