diff options
3 files changed, 319 insertions, 4 deletions
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 @@ | |||
| 74 | android:name="android.support.FILE_PROVIDER_PATHS" | 74 | android:name="android.support.FILE_PROVIDER_PATHS" |
| 75 | android:resource="@xml/nnf_provider_paths" /> | 75 | android:resource="@xml/nnf_provider_paths" /> |
| 76 | </provider> | 76 | </provider> |
| 77 | |||
| 78 | <provider | ||
| 79 | android:name=".features.DocumentProvider" | ||
| 80 | android:authorities="${applicationId}.user" | ||
| 81 | android:grantUriPermissions="true" | ||
| 82 | android:exported="true" | ||
| 83 | android:permission="android.permission.MANAGE_DOCUMENTS"> | ||
| 84 | <intent-filter> | ||
| 85 | <action android:name="android.content.action.DOCUMENTS_PROVIDER" /> | ||
| 86 | </intent-filter> | ||
| 87 | </provider> | ||
| 88 | |||
| 77 | </application> | 89 | </application> |
| 78 | 90 | ||
| 79 | </manifest> | 91 | </manifest> |
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 | |||
| 8 | import android.app.NotificationManager | 8 | import android.app.NotificationManager |
| 9 | import android.content.Context | 9 | import android.content.Context |
| 10 | import org.yuzu.yuzu_emu.model.GameDatabase | 10 | import org.yuzu.yuzu_emu.model.GameDatabase |
| 11 | import org.yuzu.yuzu_emu.utils.DirectoryInitialization.start | 11 | import org.yuzu.yuzu_emu.utils.DirectoryInitialization |
| 12 | import org.yuzu.yuzu_emu.utils.DocumentsTree | 12 | import org.yuzu.yuzu_emu.utils.DocumentsTree |
| 13 | import org.yuzu.yuzu_emu.utils.GpuDriverHelper | 13 | import org.yuzu.yuzu_emu.utils.GpuDriverHelper |
| 14 | import java.io.File | ||
| 15 | |||
| 16 | fun Context.getPublicFilesDir() : File = getExternalFilesDir(null) ?: filesDir | ||
| 14 | 17 | ||
| 15 | class YuzuApplication : Application() { | 18 | class YuzuApplication : Application() { |
| 16 | private fun createNotificationChannel() { | 19 | private fun createNotificationChannel() { |
| @@ -36,7 +39,7 @@ class YuzuApplication : Application() { | |||
| 36 | super.onCreate() | 39 | super.onCreate() |
| 37 | application = this | 40 | application = this |
| 38 | documentsTree = DocumentsTree() | 41 | documentsTree = DocumentsTree() |
| 39 | start(applicationContext) | 42 | DirectoryInitialization.start(applicationContext) |
| 40 | GpuDriverHelper.initializeDriverParameters(applicationContext) | 43 | GpuDriverHelper.initializeDriverParameters(applicationContext) |
| 41 | NativeLibrary.LogDeviceInfo() | 44 | NativeLibrary.LogDeviceInfo() |
| 42 | 45 | ||
| @@ -50,10 +53,10 @@ class YuzuApplication : Application() { | |||
| 50 | 53 | ||
| 51 | @JvmField | 54 | @JvmField |
| 52 | var documentsTree: DocumentsTree? = null | 55 | var documentsTree: DocumentsTree? = null |
| 53 | private var application: YuzuApplication? = null | 56 | lateinit var application: YuzuApplication |
| 54 | 57 | ||
| 55 | @JvmStatic | 58 | @JvmStatic |
| 56 | val appContext: Context | 59 | val appContext: Context |
| 57 | get() = application!!.applicationContext | 60 | get() = application.applicationContext |
| 58 | } | 61 | } |
| 59 | } | 62 | } |
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 @@ | |||
| 1 | // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||
| 2 | // SPDX-License-Identifier: GPL-2.0-or-later | ||
| 3 | |||
| 4 | // SPDX-License-Identifier: MPL-2.0 | ||
| 5 | // Copyright © 2023 Skyline Team and Contributors (https://github.com/skyline-emu/) | ||
| 6 | |||
| 7 | package org.yuzu.yuzu_emu.features | ||
| 8 | |||
| 9 | import android.database.Cursor | ||
| 10 | import android.database.MatrixCursor | ||
| 11 | import android.os.CancellationSignal | ||
| 12 | import android.os.ParcelFileDescriptor | ||
| 13 | import android.provider.DocumentsContract | ||
| 14 | import android.provider.DocumentsProvider | ||
| 15 | import android.webkit.MimeTypeMap | ||
| 16 | import org.yuzu.yuzu_emu.R | ||
| 17 | import org.yuzu.yuzu_emu.YuzuApplication | ||
| 18 | import org.yuzu.yuzu_emu.getPublicFilesDir | ||
| 19 | import java.io.* | ||
| 20 | |||
| 21 | class DocumentProvider : DocumentsProvider() { | ||
| 22 | private val baseDirectory: File | ||
| 23 | get() = File(YuzuApplication.application.getPublicFilesDir().canonicalPath) | ||
| 24 | |||
| 25 | companion object { | ||
| 26 | private val DEFAULT_ROOT_PROJECTION: Array<String> = arrayOf( | ||
| 27 | DocumentsContract.Root.COLUMN_ROOT_ID, | ||
| 28 | DocumentsContract.Root.COLUMN_MIME_TYPES, | ||
| 29 | DocumentsContract.Root.COLUMN_FLAGS, | ||
| 30 | DocumentsContract.Root.COLUMN_ICON, | ||
| 31 | DocumentsContract.Root.COLUMN_TITLE, | ||
| 32 | DocumentsContract.Root.COLUMN_SUMMARY, | ||
| 33 | DocumentsContract.Root.COLUMN_DOCUMENT_ID, | ||
| 34 | DocumentsContract.Root.COLUMN_AVAILABLE_BYTES | ||
| 35 | ) | ||
| 36 | |||
| 37 | private val DEFAULT_DOCUMENT_PROJECTION: Array<String> = arrayOf( | ||
| 38 | DocumentsContract.Document.COLUMN_DOCUMENT_ID, | ||
| 39 | DocumentsContract.Document.COLUMN_MIME_TYPE, | ||
| 40 | DocumentsContract.Document.COLUMN_DISPLAY_NAME, | ||
| 41 | DocumentsContract.Document.COLUMN_LAST_MODIFIED, | ||
| 42 | DocumentsContract.Document.COLUMN_FLAGS, | ||
| 43 | DocumentsContract.Document.COLUMN_SIZE | ||
| 44 | ) | ||
| 45 | |||
| 46 | const val ROOT_ID: String = "root" | ||
| 47 | } | ||
| 48 | |||
| 49 | override fun onCreate(): Boolean { | ||
| 50 | return true | ||
| 51 | } | ||
| 52 | |||
| 53 | /** | ||
| 54 | * @return The [File] that corresponds to the document ID supplied by [getDocumentId] | ||
| 55 | */ | ||
| 56 | private fun getFile(documentId: String): File { | ||
| 57 | if (documentId.startsWith(ROOT_ID)) { | ||
| 58 | val file = baseDirectory.resolve(documentId.drop(ROOT_ID.length + 1)) | ||
| 59 | if (!file.exists()) throw FileNotFoundException("${file.absolutePath} ($documentId) not found") | ||
| 60 | return file | ||
| 61 | } else { | ||
| 62 | throw FileNotFoundException("'$documentId' is not in any known root") | ||
| 63 | } | ||
| 64 | } | ||
| 65 | |||
| 66 | /** | ||
| 67 | * @return A unique ID for the provided [File] | ||
| 68 | */ | ||
| 69 | private fun getDocumentId(file: File): String { | ||
| 70 | return "$ROOT_ID/${file.toRelativeString(baseDirectory)}" | ||
| 71 | } | ||
| 72 | |||
| 73 | override fun queryRoots(projection: Array<out String>?): Cursor { | ||
| 74 | val cursor = MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION) | ||
| 75 | |||
| 76 | cursor.newRow().apply { | ||
| 77 | add(DocumentsContract.Root.COLUMN_ROOT_ID, ROOT_ID) | ||
| 78 | add(DocumentsContract.Root.COLUMN_SUMMARY, null) | ||
| 79 | add( | ||
| 80 | DocumentsContract.Root.COLUMN_FLAGS, | ||
| 81 | DocumentsContract.Root.FLAG_SUPPORTS_CREATE or DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD | ||
| 82 | ) | ||
| 83 | add(DocumentsContract.Root.COLUMN_TITLE, context!!.getString(R.string.app_name)) | ||
| 84 | add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, getDocumentId(baseDirectory)) | ||
| 85 | add(DocumentsContract.Root.COLUMN_MIME_TYPES, "*/*") | ||
| 86 | add(DocumentsContract.Root.COLUMN_AVAILABLE_BYTES, baseDirectory.freeSpace) | ||
| 87 | add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_yuzu) | ||
| 88 | } | ||
| 89 | |||
| 90 | return cursor | ||
| 91 | } | ||
| 92 | |||
| 93 | override fun queryDocument(documentId: String?, projection: Array<out String>?): Cursor { | ||
| 94 | val cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION) | ||
| 95 | return includeFile(cursor, documentId, null) | ||
| 96 | } | ||
| 97 | |||
| 98 | override fun isChildDocument(parentDocumentId: String?, documentId: String?): Boolean { | ||
| 99 | return documentId?.startsWith(parentDocumentId!!) ?: false | ||
| 100 | } | ||
| 101 | |||
| 102 | /** | ||
| 103 | * @return A new [File] with a unique name based off the supplied [name], not conflicting with any existing file | ||
| 104 | */ | ||
| 105 | private fun File.resolveWithoutConflict(name: String): File { | ||
| 106 | var file = resolve(name) | ||
| 107 | if (file.exists()) { | ||
| 108 | var noConflictId = | ||
| 109 | 1 // Makes sure two files don't have the same name by adding a number to the end | ||
| 110 | val extension = name.substringAfterLast('.') | ||
| 111 | val baseName = name.substringBeforeLast('.') | ||
| 112 | while (file.exists()) | ||
| 113 | file = resolve("$baseName (${noConflictId++}).$extension") | ||
| 114 | } | ||
| 115 | return file | ||
| 116 | } | ||
| 117 | |||
| 118 | override fun createDocument( | ||
| 119 | parentDocumentId: String?, | ||
| 120 | mimeType: String?, | ||
| 121 | displayName: String | ||
| 122 | ): String { | ||
| 123 | val parentFile = getFile(parentDocumentId!!) | ||
| 124 | val newFile = parentFile.resolveWithoutConflict(displayName) | ||
| 125 | |||
| 126 | try { | ||
| 127 | if (DocumentsContract.Document.MIME_TYPE_DIR == mimeType) { | ||
| 128 | if (!newFile.mkdir()) | ||
| 129 | throw IOException("Failed to create directory") | ||
| 130 | } else { | ||
| 131 | if (!newFile.createNewFile()) | ||
| 132 | throw IOException("Failed to create file") | ||
| 133 | } | ||
| 134 | } catch (e: IOException) { | ||
| 135 | throw FileNotFoundException("Couldn't create document '${newFile.path}': ${e.message}") | ||
| 136 | } | ||
| 137 | |||
| 138 | return getDocumentId(newFile) | ||
| 139 | } | ||
| 140 | |||
| 141 | override fun deleteDocument(documentId: String?) { | ||
| 142 | val file = getFile(documentId!!) | ||
| 143 | if (!file.delete()) | ||
| 144 | throw FileNotFoundException("Couldn't delete document with ID '$documentId'") | ||
| 145 | } | ||
| 146 | |||
| 147 | override fun removeDocument(documentId: String, parentDocumentId: String?) { | ||
| 148 | val parent = getFile(parentDocumentId!!) | ||
| 149 | val file = getFile(documentId) | ||
| 150 | |||
| 151 | if (parent == file || file.parentFile == null || file.parentFile!! == parent) { | ||
| 152 | if (!file.delete()) | ||
| 153 | throw FileNotFoundException("Couldn't delete document with ID '$documentId'") | ||
| 154 | } else { | ||
| 155 | throw FileNotFoundException("Couldn't delete document with ID '$documentId'") | ||
| 156 | } | ||
| 157 | } | ||
| 158 | |||
| 159 | override fun renameDocument(documentId: String?, displayName: String?): String { | ||
| 160 | if (displayName == null) | ||
| 161 | throw FileNotFoundException("Couldn't rename document '$documentId' as the new name is null") | ||
| 162 | |||
| 163 | val sourceFile = getFile(documentId!!) | ||
| 164 | val sourceParentFile = sourceFile.parentFile | ||
| 165 | ?: throw FileNotFoundException("Couldn't rename document '$documentId' as it has no parent") | ||
| 166 | val destFile = sourceParentFile.resolve(displayName) | ||
| 167 | |||
| 168 | try { | ||
| 169 | if (!sourceFile.renameTo(destFile)) | ||
| 170 | throw FileNotFoundException("Couldn't rename document from '${sourceFile.name}' to '${destFile.name}'") | ||
| 171 | } catch (e: Exception) { | ||
| 172 | throw FileNotFoundException("Couldn't rename document from '${sourceFile.name}' to '${destFile.name}': ${e.message}") | ||
| 173 | } | ||
| 174 | |||
| 175 | return getDocumentId(destFile) | ||
| 176 | } | ||
| 177 | |||
| 178 | private fun copyDocument( | ||
| 179 | sourceDocumentId: String, sourceParentDocumentId: String, | ||
| 180 | targetParentDocumentId: String? | ||
| 181 | ): String { | ||
| 182 | if (!isChildDocument(sourceParentDocumentId, sourceDocumentId)) | ||
| 183 | throw FileNotFoundException("Couldn't copy document '$sourceDocumentId' as its parent is not '$sourceParentDocumentId'") | ||
| 184 | |||
| 185 | return copyDocument(sourceDocumentId, targetParentDocumentId) | ||
| 186 | } | ||
| 187 | |||
| 188 | override fun copyDocument(sourceDocumentId: String, targetParentDocumentId: String?): String { | ||
| 189 | val parent = getFile(targetParentDocumentId!!) | ||
| 190 | val oldFile = getFile(sourceDocumentId) | ||
| 191 | val newFile = parent.resolveWithoutConflict(oldFile.name) | ||
| 192 | |||
| 193 | try { | ||
| 194 | if (!(newFile.createNewFile() && newFile.setWritable(true) && newFile.setReadable(true))) | ||
| 195 | throw IOException("Couldn't create new file") | ||
| 196 | |||
| 197 | FileInputStream(oldFile).use { inStream -> | ||
| 198 | FileOutputStream(newFile).use { outStream -> | ||
| 199 | inStream.copyTo(outStream) | ||
| 200 | } | ||
| 201 | } | ||
| 202 | } catch (e: IOException) { | ||
| 203 | throw FileNotFoundException("Couldn't copy document '$sourceDocumentId': ${e.message}") | ||
| 204 | } | ||
| 205 | |||
| 206 | return getDocumentId(newFile) | ||
| 207 | } | ||
| 208 | |||
| 209 | override fun moveDocument( | ||
| 210 | sourceDocumentId: String, sourceParentDocumentId: String?, | ||
| 211 | targetParentDocumentId: String? | ||
| 212 | ): String { | ||
| 213 | try { | ||
| 214 | val newDocumentId = copyDocument( | ||
| 215 | sourceDocumentId, sourceParentDocumentId!!, | ||
| 216 | targetParentDocumentId | ||
| 217 | ) | ||
| 218 | removeDocument(sourceDocumentId, sourceParentDocumentId) | ||
| 219 | return newDocumentId | ||
| 220 | } catch (e: FileNotFoundException) { | ||
| 221 | throw FileNotFoundException("Couldn't move document '$sourceDocumentId'") | ||
| 222 | } | ||
| 223 | } | ||
| 224 | |||
| 225 | private fun includeFile(cursor: MatrixCursor, documentId: String?, file: File?): MatrixCursor { | ||
| 226 | val localDocumentId = documentId ?: file?.let { getDocumentId(it) } | ||
| 227 | val localFile = file ?: getFile(documentId!!) | ||
| 228 | |||
| 229 | var flags = 0 | ||
| 230 | if (localFile.isDirectory && localFile.canWrite()) { | ||
| 231 | flags = DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE | ||
| 232 | } else if (localFile.canWrite()) { | ||
| 233 | flags = DocumentsContract.Document.FLAG_SUPPORTS_WRITE | ||
| 234 | flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_DELETE | ||
| 235 | |||
| 236 | flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_REMOVE | ||
| 237 | flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_MOVE | ||
| 238 | flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_COPY | ||
| 239 | flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_RENAME | ||
| 240 | } | ||
| 241 | |||
| 242 | cursor.newRow().apply { | ||
| 243 | add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, localDocumentId) | ||
| 244 | add( | ||
| 245 | DocumentsContract.Document.COLUMN_DISPLAY_NAME, | ||
| 246 | if (localFile == baseDirectory) context!!.getString(R.string.app_name) else localFile.name | ||
| 247 | ) | ||
| 248 | add(DocumentsContract.Document.COLUMN_SIZE, localFile.length()) | ||
| 249 | add(DocumentsContract.Document.COLUMN_MIME_TYPE, getTypeForFile(localFile)) | ||
| 250 | add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, localFile.lastModified()) | ||
| 251 | add(DocumentsContract.Document.COLUMN_FLAGS, flags) | ||
| 252 | if (localFile == baseDirectory) | ||
| 253 | add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_yuzu) | ||
| 254 | } | ||
| 255 | |||
| 256 | return cursor | ||
| 257 | } | ||
| 258 | |||
| 259 | private fun getTypeForFile(file: File): Any { | ||
| 260 | return if (file.isDirectory) | ||
| 261 | DocumentsContract.Document.MIME_TYPE_DIR | ||
| 262 | else | ||
| 263 | getTypeForName(file.name) | ||
| 264 | } | ||
| 265 | |||
| 266 | private fun getTypeForName(name: String): Any { | ||
| 267 | val lastDot = name.lastIndexOf('.') | ||
| 268 | if (lastDot >= 0) { | ||
| 269 | val extension = name.substring(lastDot + 1) | ||
| 270 | val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) | ||
| 271 | if (mime != null) | ||
| 272 | return mime | ||
| 273 | } | ||
| 274 | return "application/octect-stream" | ||
| 275 | } | ||
| 276 | |||
| 277 | override fun queryChildDocuments( | ||
| 278 | parentDocumentId: String?, | ||
| 279 | projection: Array<out String>?, | ||
| 280 | sortOrder: String? | ||
| 281 | ): Cursor { | ||
| 282 | var cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION) | ||
| 283 | |||
| 284 | val parent = getFile(parentDocumentId!!) | ||
| 285 | for (file in parent.listFiles()!!) | ||
| 286 | cursor = includeFile(cursor, null, file) | ||
| 287 | |||
| 288 | return cursor | ||
| 289 | } | ||
| 290 | |||
| 291 | override fun openDocument( | ||
| 292 | documentId: String?, | ||
| 293 | mode: String?, | ||
| 294 | signal: CancellationSignal? | ||
| 295 | ): ParcelFileDescriptor { | ||
| 296 | val file = documentId?.let { getFile(it) } | ||
| 297 | val accessMode = ParcelFileDescriptor.parseMode(mode) | ||
| 298 | return ParcelFileDescriptor.open(file, accessMode) | ||
| 299 | } | ||
| 300 | } | ||