diff options
Diffstat (limited to 'src/android')
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 @@                  android:name="android.support.FILE_PROVIDER_PATHS"                  android:resource="@xml/nnf_provider_paths" />          </provider> + +        <provider +            android:name=".features.DocumentProvider" +            android:authorities="${applicationId}.user" +            android:grantUriPermissions="true" +            android:exported="true" +            android:permission="android.permission.MANAGE_DOCUMENTS"> +            <intent-filter> +                <action android:name="android.content.action.DOCUMENTS_PROVIDER" /> +            </intent-filter> +        </provider> +      </application>  </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  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<String> = 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<String> = 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<out String>?): 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<out String>?): 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<out String>?, +        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) +    } +}  | 
