android - add log export, providers to browse app data

This commit is contained in:
Emmanuel Hansen 2023-12-08 19:45:05 +00:00
parent eb510ae94f
commit 574b74bdea
8 changed files with 409 additions and 4 deletions

View File

@ -17,6 +17,7 @@
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<application
android:name=".RyujinxApplication"
android:allowBackup="true"
android:appCategory="game"
android:dataExtractionRules="@xml/data_extraction_rules"
@ -39,6 +40,26 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="org.ryujinx.android.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
<provider
android:name=".providers.DocumentProvider"
android:authorities="org.ryujinx.android.provider"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
</application>
</manifest>

View File

@ -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)
}
}

View File

@ -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()
}
}

View File

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

View File

@ -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<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 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<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, 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<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
*/
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<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)
}
}

View File

@ -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<Double>? = null
private var gameFpsState: MutableState<Double>? = null
private var fifoState: MutableState<Double>? = null
@ -38,6 +40,7 @@ class MainViewModel(val activity: MainActivity) {
private var progressValue: MutableState<Float>? = null
private var showLoading: MutableState<Boolean>? = null
private var refreshUser: MutableState<Boolean>? = null
var gameHost: GameHost? = null
set(value) {
field = value

View File

@ -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() {

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path
name="external"
path="/" />
<external-files-path
name="external_files"
path="/" />
<files-path
name="files"
path="/" />
</paths>