forked from MeloNX/MeloNX
android - add log export, providers to browse app data
This commit is contained in:
parent
990ad3d133
commit
819c7671bb
@ -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>
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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() {
|
||||
|
12
src/RyujinxAndroid/app/src/main/res/xml/provider_paths.xml
Normal file
12
src/RyujinxAndroid/app/src/main/res/xml/provider_paths.xml
Normal 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>
|
Loading…
x
Reference in New Issue
Block a user