From 819c7671bbbadcd180f6a457892564f6916f853c Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Fri, 8 Dec 2023 19:45:05 +0000 Subject: [PATCH] android - add log export, providers to browse app data --- .../app/src/main/AndroidManifest.xml | 21 ++ .../java/org/ryujinx/android/CrashHandler.kt | 2 +- .../main/java/org/ryujinx/android/Logging.kt | 54 ++++ .../org/ryujinx/android/RyujinxApplication.kt | 19 ++ .../android/providers/DocumentProvider.kt | 279 ++++++++++++++++++ .../android/viewmodels/MainViewModel.kt | 3 + .../org/ryujinx/android/views/SettingViews.kt | 23 +- .../app/src/main/res/xml/provider_paths.xml | 12 + 8 files changed, 409 insertions(+), 4 deletions(-) create mode 100644 src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/Logging.kt create mode 100644 src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/RyujinxApplication.kt create mode 100644 src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/providers/DocumentProvider.kt create mode 100644 src/RyujinxAndroid/app/src/main/res/xml/provider_paths.xml diff --git a/src/RyujinxAndroid/app/src/main/AndroidManifest.xml b/src/RyujinxAndroid/app/src/main/AndroidManifest.xml index 549ed4ed2..84deae2b2 100644 --- a/src/RyujinxAndroid/app/src/main/AndroidManifest.xml +++ b/src/RyujinxAndroid/app/src/main/AndroidManifest.xml @@ -17,6 +17,7 @@ + + + + + + + + + diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/CrashHandler.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/CrashHandler.kt index 2d6503020..f6c2eb656 100644 --- a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/CrashHandler.kt +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/CrashHandler.kt @@ -8,6 +8,6 @@ class CrashHandler : UncaughtExceptionHandler { override fun uncaughtException(t: Thread, e: Throwable) { crashLog += e.toString() + "\n" - File(MainActivity.AppPath + "${File.separator}crash.log").writeText(crashLog) + File(MainActivity.AppPath + "${File.separator}Logs${File.separator}crash.log").writeText(crashLog) } } diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/Logging.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/Logging.kt new file mode 100644 index 000000000..a38c6a8da --- /dev/null +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/Logging.kt @@ -0,0 +1,54 @@ +package org.ryujinx.android + +import android.content.Intent +import androidx.core.content.FileProvider +import net.lingala.zip4j.ZipFile +import org.ryujinx.android.viewmodels.MainViewModel +import java.io.File +import java.net.URLConnection + +class Logging(private var viewModel: MainViewModel) { + val logPath = MainActivity.AppPath + "/Logs" + init{ + File(logPath).mkdirs() + } + + fun requestExport(){ + val files = File(logPath).listFiles() + files?.apply { + val zipExportPath = MainActivity.AppPath + "/log.zip" + File(zipExportPath).delete() + var count = 0 + if (files.isNotEmpty()) { + val zipFile = ZipFile(zipExportPath) + for (file in files) { + if(file.isFile) { + zipFile.addFile(file) + count++ + } + } + zipFile.close() + } + if (count > 0) { + val zip =File (zipExportPath) + val uri = FileProvider.getUriForFile(viewModel.activity, viewModel.activity.packageName + ".fileprovider", zip) + val intent = Intent(Intent.ACTION_SEND) + intent.putExtra(Intent.EXTRA_STREAM, uri) + intent.setDataAndType(uri, URLConnection.guessContentTypeFromName(zip.name)) + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + val chooser = Intent.createChooser(intent, "Share logs"); + viewModel.activity.startActivity(chooser) + } else { + File(zipExportPath).delete() + } + } + } + + fun clearLogs() { + if(File(logPath).exists()){ + File(logPath).deleteRecursively() + } + + File(logPath).mkdirs() + } +} diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/RyujinxApplication.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/RyujinxApplication.kt new file mode 100644 index 000000000..69a71d5c5 --- /dev/null +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/RyujinxApplication.kt @@ -0,0 +1,19 @@ +package org.ryujinx.android + +import android.app.Application +import android.content.Context +import java.io.File + +class RyujinxApplication : Application() { + init { + instance = this + } + + fun getPublicFilesDir() : File = getExternalFilesDir(null) ?: filesDir + companion object { + lateinit var instance : RyujinxApplication + private set + + val context : Context get() = instance.applicationContext + } +} diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/providers/DocumentProvider.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/providers/DocumentProvider.kt new file mode 100644 index 000000000..573cdf4ae --- /dev/null +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/providers/DocumentProvider.kt @@ -0,0 +1,279 @@ +package org.ryujinx.android.providers + +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.ryujinx.android.R +import org.ryujinx.android.RyujinxApplication +import java.io.File +import java.io.FileInputStream +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.io.IOException + +class DocumentProvider : DocumentsProvider() { + private val baseDirectory = File(RyujinxApplication.instance.getPublicFilesDir().canonicalPath) + private val applicationName = "Ryujinx" + + 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 AUTHORITY : String = "org.ryujinx.android.providers" + + 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, applicationName) + 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_launcher_foreground) + } + + 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 + */ + 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) applicationName 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_launcher_foreground) + } + + 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) + } +} diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/MainViewModel.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/MainViewModel.kt index 7f451449d..c5ac4a081 100644 --- a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/MainViewModel.kt +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/MainViewModel.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.sync.Semaphore import org.ryujinx.android.GameController import org.ryujinx.android.GameHost import org.ryujinx.android.GraphicsConfiguration +import org.ryujinx.android.Logging import org.ryujinx.android.MainActivity import org.ryujinx.android.NativeGraphicsInterop import org.ryujinx.android.NativeHelpers @@ -31,6 +32,7 @@ class MainViewModel(val activity: MainActivity) { var selected: GameModel? = null var isMiiEditorLaunched = false val userViewModel = UserViewModel() + val logging = Logging(this) private var gameTimeState: MutableState? = null private var gameFpsState: MutableState? = null private var fifoState: MutableState? = null @@ -38,6 +40,7 @@ class MainViewModel(val activity: MainActivity) { private var progressValue: MutableState? = null private var showLoading: MutableState? = null private var refreshUser: MutableState? = null + var gameHost: GameHost? = null set(value) { field = value diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/SettingViews.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/SettingViews.kt index d6be07e01..bb68d75a2 100644 --- a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/SettingViews.kt +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/SettingViews.kt @@ -377,9 +377,11 @@ class SettingViews { modifier = Modifier, shape = MaterialTheme.shapes.medium ) { - Text(modifier = Modifier - .padding(24.dp), - text = "App Data import completed.") + Text( + modifier = Modifier + .padding(24.dp), + text = "App Data import completed." + ) } } } @@ -634,6 +636,21 @@ class SettingViews { } } } + ExpandableView(onCardArrowClick = { }, title = "Log") { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Button(onClick = { + mainViewModel.logging.requestExport() + }) { + Text(text = "Send Logs") + } + } + } } BackHandler() { diff --git a/src/RyujinxAndroid/app/src/main/res/xml/provider_paths.xml b/src/RyujinxAndroid/app/src/main/res/xml/provider_paths.xml new file mode 100644 index 000000000..cfbfdb5bb --- /dev/null +++ b/src/RyujinxAndroid/app/src/main/res/xml/provider_paths.xml @@ -0,0 +1,12 @@ + + + + + +