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 @@
+
+
+
+
+
+