From f403484276c050008098ada9b2974cf08a9ad6b6 Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Sat, 30 Dec 2023 12:23:07 +0000 Subject: [PATCH] android - move title updates support to SAF --- src/LibRyujinx/Android/JniExportedMethods.cs | 5 +- src/LibRyujinx/LibRyujinx.Device.cs | 6 +- .../PartitionFileSystemExtensions.cs | 79 ++++++++++-------- .../Loaders/Processes/ProcessLoader.cs | 8 +- src/Ryujinx.HLE/Switch.cs | 6 +- .../java/org/ryujinx/android/RyujinxNative.kt | 2 +- .../ryujinx/android/viewmodels/GameModel.kt | 22 +++++ .../android/viewmodels/MainViewModel.kt | 4 +- .../viewmodels/TitleUpdateViewModel.kt | 83 ++++++++++++------- .../ryujinx/android/views/TitleUpdateViews.kt | 78 +++++++---------- 10 files changed, 165 insertions(+), 128 deletions(-) diff --git a/src/LibRyujinx/Android/JniExportedMethods.cs b/src/LibRyujinx/Android/JniExportedMethods.cs index 9cc8e848c..7be533091 100644 --- a/src/LibRyujinx/Android/JniExportedMethods.cs +++ b/src/LibRyujinx/Android/JniExportedMethods.cs @@ -262,7 +262,7 @@ namespace LibRyujinx } [UnmanagedCallersOnly(EntryPoint = "Java_org_ryujinx_android_RyujinxNative_deviceLoadDescriptor")] - public static JBoolean JniLoadApplicationNative(JEnvRef jEnv, JObjectLocalRef jObj, JInt descriptor, JInt type) + public static JBoolean JniLoadApplicationNative(JEnvRef jEnv, JObjectLocalRef jObj, JInt descriptor, JInt type, JInt updateDescriptor) { Logger.Trace?.Print(LogClass.Application, "Jni Function Call"); if (SwitchDevice?.EmulationContext == null) @@ -271,8 +271,9 @@ namespace LibRyujinx } var stream = OpenFile(descriptor); + var update = updateDescriptor == -1 ? null : OpenFile(updateDescriptor); - return LoadApplication(stream, (FileType)(int)type); + return LoadApplication(stream, (FileType)(int)type, update); } [UnmanagedCallersOnly(EntryPoint = "Java_org_ryujinx_android_RyujinxNative_deviceVerifyFirmware")] diff --git a/src/LibRyujinx/LibRyujinx.Device.cs b/src/LibRyujinx/LibRyujinx.Device.cs index d46d03f43..839852933 100644 --- a/src/LibRyujinx/LibRyujinx.Device.cs +++ b/src/LibRyujinx/LibRyujinx.Device.cs @@ -77,14 +77,14 @@ namespace LibRyujinx return SwitchDevice?.ContentManager?.VerifyFirmwarePackage(stream, isXci) ?? null; } - public static bool LoadApplication(Stream stream, FileType type) + public static bool LoadApplication(Stream stream, FileType type, Stream? updateStream = null) { var emulationContext = SwitchDevice.EmulationContext; return type switch { FileType.None => false, - FileType.Nsp => emulationContext?.LoadNsp(stream) ?? false, - FileType.Xci => emulationContext?.LoadXci(stream) ?? false, + FileType.Nsp => emulationContext?.LoadNsp(stream, updateStream) ?? false, + FileType.Xci => emulationContext?.LoadXci(stream, updateStream) ?? false, FileType.Nro => emulationContext?.LoadProgram(stream, true, "") ?? false, }; } diff --git a/src/Ryujinx.HLE/Loaders/Processes/Extensions/PartitionFileSystemExtensions.cs b/src/Ryujinx.HLE/Loaders/Processes/Extensions/PartitionFileSystemExtensions.cs index 6a01d2f41..68f4305b2 100644 --- a/src/Ryujinx.HLE/Loaders/Processes/Extensions/PartitionFileSystemExtensions.cs +++ b/src/Ryujinx.HLE/Loaders/Processes/Extensions/PartitionFileSystemExtensions.cs @@ -20,7 +20,7 @@ namespace Ryujinx.HLE.Loaders.Processes.Extensions private static readonly DownloadableContentJsonSerializerContext _contentSerializerContext = new(JsonHelper.GetDefaultSerializerOptions()); private static readonly TitleUpdateMetadataJsonSerializerContext _titleSerializerContext = new(JsonHelper.GetDefaultSerializerOptions()); - internal static (bool, ProcessResult) TryLoad(this PartitionFileSystemCore partitionFileSystem, Switch device, Stream stream, out string errorMessage, string extension) + internal static (bool, ProcessResult) TryLoad(this PartitionFileSystemCore partitionFileSystem, Switch device, Stream stream, out string errorMessage, string extension, Stream updateStream = null) where TMetaData : PartitionFileSystemMetaCore, new() where TFormat : IPartitionFileSystemFormat where THeader : unmanaged, IPartitionFileSystemHeader @@ -87,42 +87,21 @@ namespace Ryujinx.HLE.Loaders.Processes.Extensions { // Clear the program index part. titleIdBase &= ~0xFUL; - - // Load update information if exists. - string titleUpdateMetadataPath = System.IO.Path.Combine(AppDataManager.GamesDirPath, titleIdBase.ToString("x16"), "updates.json"); - if (File.Exists(titleUpdateMetadataPath)) + PartitionFileSystem updatePartitionFileSystem = new(); + if (updateStream != null) { - string updatePath = JsonHelper.DeserializeFromFile(titleUpdateMetadataPath, _titleSerializerContext.TitleUpdateMetadata).Selected; - if (File.Exists(updatePath)) + LoadUpdate(device, updateStream, ref updatePatchNca, ref updateControlNca, titleIdBase, updatePartitionFileSystem); + } + else + { + // Load update information if exists. + string titleUpdateMetadataPath = System.IO.Path.Combine(AppDataManager.GamesDirPath, titleIdBase.ToString("x16"), "updates.json"); + if (File.Exists(titleUpdateMetadataPath)) { - PartitionFileSystem updatePartitionFileSystem = new(); - updatePartitionFileSystem.Initialize(new FileStream(updatePath, FileMode.Open, FileAccess.Read).AsStorage()).ThrowIfFailure(); - - device.Configuration.VirtualFileSystem.ImportTickets(updatePartitionFileSystem); - - // TODO: This should use CNMT NCA instead. - foreach (DirectoryEntryEx fileEntry in updatePartitionFileSystem.EnumerateEntries("/", "*.nca")) + string updatePath = JsonHelper.DeserializeFromFile(titleUpdateMetadataPath, _titleSerializerContext.TitleUpdateMetadata).Selected; + if (File.Exists(updatePath)) { - Nca nca = updatePartitionFileSystem.GetNca(device, fileEntry.FullPath); - - if (nca.GetProgramIndex() != device.Configuration.UserChannelPersistence.Index) - { - continue; - } - - if ($"{nca.Header.TitleId.ToString("x16")[..^3]}000" != titleIdBase.ToString("x16")) - { - break; - } - - if (nca.IsProgram()) - { - updatePatchNca = nca; - } - else if (nca.IsControl()) - { - updateControlNca = nca; - } + LoadUpdate(device, new FileStream(updatePath, FileMode.Open, FileAccess.Read), ref updatePatchNca, ref updateControlNca, titleIdBase, updatePartitionFileSystem); } } } @@ -171,6 +150,38 @@ namespace Ryujinx.HLE.Loaders.Processes.Extensions errorMessage = "Unable to load: Could not find Main NCA"; return (false, ProcessResult.Failed); + + static void LoadUpdate(Switch device, Stream updateStream, ref Nca updatePatchNca, ref Nca updateControlNca, ulong titleIdBase, PartitionFileSystem updatePartitionFileSystem) + { + updatePartitionFileSystem.Initialize(updateStream.AsStorage()).ThrowIfFailure(); + + device.Configuration.VirtualFileSystem.ImportTickets(updatePartitionFileSystem); + + // TODO: This should use CNMT NCA instead. + foreach (DirectoryEntryEx fileEntry in updatePartitionFileSystem.EnumerateEntries("/", "*.nca")) + { + Nca nca = updatePartitionFileSystem.GetNca(device, fileEntry.FullPath); + + if (nca.GetProgramIndex() != device.Configuration.UserChannelPersistence.Index) + { + continue; + } + + if ($"{nca.Header.TitleId.ToString("x16")[..^3]}000" != titleIdBase.ToString("x16")) + { + break; + } + + if (nca.IsProgram()) + { + updatePatchNca = nca; + } + else if (nca.IsControl()) + { + updateControlNca = nca; + } + } + } } public static Nca GetNca(this IFileSystem fileSystem, Switch device, string path) diff --git a/src/Ryujinx.HLE/Loaders/Processes/ProcessLoader.cs b/src/Ryujinx.HLE/Loaders/Processes/ProcessLoader.cs index 2246b9994..966d692ef 100644 --- a/src/Ryujinx.HLE/Loaders/Processes/ProcessLoader.cs +++ b/src/Ryujinx.HLE/Loaders/Processes/ProcessLoader.cs @@ -39,7 +39,7 @@ namespace Ryujinx.HLE.Loaders.Processes return LoadXci(stream); } - public bool LoadXci(Stream stream) + public bool LoadXci(Stream stream, Stream updateStream = null) { Xci xci = new(_device.Configuration.VirtualFileSystem.KeySet, stream.AsStorage()); @@ -50,7 +50,7 @@ namespace Ryujinx.HLE.Loaders.Processes return false; } - (bool success, ProcessResult processResult) = xci.OpenPartition(XciPartitionType.Secure).TryLoad(_device, stream, out string errorMessage, "xci"); + (bool success, ProcessResult processResult) = xci.OpenPartition(XciPartitionType.Secure).TryLoad(_device, stream, out string errorMessage, "xci", updateStream); if (!success) { @@ -79,12 +79,12 @@ namespace Ryujinx.HLE.Loaders.Processes return LoadNsp(file); } - public bool LoadNsp(Stream stream) + public bool LoadNsp(Stream stream, Stream updateStream = null) { PartitionFileSystem partitionFileSystem = new(); partitionFileSystem.Initialize(stream.AsStorage()).ThrowIfFailure(); - (bool success, ProcessResult processResult) = partitionFileSystem.TryLoad(_device, stream, out string errorMessage, "nsp"); + (bool success, ProcessResult processResult) = partitionFileSystem.TryLoad(_device, stream, out string errorMessage, "nsp", updateStream); if (processResult.ProcessId == 0) { diff --git a/src/Ryujinx.HLE/Switch.cs b/src/Ryujinx.HLE/Switch.cs index bcf66db9d..391f842c2 100644 --- a/src/Ryujinx.HLE/Switch.cs +++ b/src/Ryujinx.HLE/Switch.cs @@ -93,7 +93,7 @@ namespace Ryujinx.HLE return Processes.LoadNxo(fileName); } - public bool LoadXci(Stream xciStream) + public bool LoadXci(Stream xciStream, Stream updateStream = null) { return Processes.LoadXci(xciStream); } @@ -103,9 +103,9 @@ namespace Ryujinx.HLE return Processes.LoadNca(ncaStream); } - public bool LoadNsp(Stream nspStream) + public bool LoadNsp(Stream nspStream, Stream updateStream = null) { - return Processes.LoadNsp(nspStream); + return Processes.LoadNsp(nspStream, updateStream); } public bool LoadProgram(Stream stream, bool isNro, string name) diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/RyujinxNative.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/RyujinxNative.kt index fc3447d49..a01d75332 100644 --- a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/RyujinxNative.kt +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/RyujinxNative.kt @@ -39,7 +39,7 @@ class RyujinxNative { external fun deviceGetGameFifo(): Double external fun deviceGetGameInfo(fileDescriptor: Int, extension: Long): GameInfo external fun deviceGetGameInfoFromPath(path: String): GameInfo - external fun deviceLoadDescriptor(fileDescriptor: Int, gameType: Int): Boolean + external fun deviceLoadDescriptor(fileDescriptor: Int, gameType: Int, updateDescriptor: Int): Boolean external fun graphicsRendererSetSize(width: Int, height: Int) external fun graphicsRendererSetVsync(enabled: Boolean) external fun graphicsRendererRunLoop() diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/GameModel.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/GameModel.kt index 47aaa7f34..017809ea0 100644 --- a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/GameModel.kt +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/GameModel.kt @@ -1,6 +1,7 @@ package org.ryujinx.android.viewmodels import android.content.Context +import android.net.Uri import android.os.ParcelFileDescriptor import androidx.documentfile.provider.DocumentFile import com.anggrayudi.storage.file.extension @@ -9,6 +10,7 @@ import org.ryujinx.android.RyujinxNative class GameModel(var file: DocumentFile, val context: Context) { + private var updateDescriptor: ParcelFileDescriptor? = null var type: FileType var descriptor: ParcelFileDescriptor? = null var fileName: String? @@ -50,9 +52,29 @@ class GameModel(var file: DocumentFile, val context: Context) { return descriptor?.fd ?: 0 } + fun openUpdate() : Int { + if(titleId?.isNotEmpty() == true) { + val vm = TitleUpdateViewModel(titleId ?: "") + + if(vm.data?.selected?.isNotEmpty() == true){ + val uri = Uri.parse(vm.data?.selected) + val file = DocumentFile.fromSingleUri(context, uri) + if(file?.exists() == true){ + updateDescriptor = context.contentResolver.openFileDescriptor(file.uri, "rw") + + return updateDescriptor ?.fd ?: -1; + } + } + } + + return -1; + } + fun close() { descriptor?.close() descriptor = null + updateDescriptor?.close() + updateDescriptor = null } } 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 e7728360c..bf538287c 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 @@ -85,6 +85,8 @@ class MainViewModel(val activity: MainActivity) { if (descriptor == 0) return false + val update = game.openUpdate() + gameModel = game isMiiEditorLaunched = false @@ -178,7 +180,7 @@ class MainViewModel(val activity: MainActivity) { if (!success) return false - success = nativeRyujinx.deviceLoadDescriptor(descriptor, game.type.ordinal) + success = nativeRyujinx.deviceLoadDescriptor(descriptor, game.type.ordinal, update) if (!success) return false diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/TitleUpdateViewModel.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/TitleUpdateViewModel.kt index 6a515f404..2f3df1b08 100644 --- a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/TitleUpdateViewModel.kt +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/TitleUpdateViewModel.kt @@ -1,12 +1,15 @@ package org.ryujinx.android.viewmodels +import android.content.Intent +import android.net.Uri import androidx.compose.runtime.MutableState import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.toLowerCase +import androidx.documentfile.provider.DocumentFile import com.anggrayudi.storage.SimpleStorageHelper +import com.anggrayudi.storage.file.extension import com.google.gson.Gson -import org.ryujinx.android.Helpers import org.ryujinx.android.MainActivity import java.io.File import kotlin.math.max @@ -16,29 +19,32 @@ class TitleUpdateViewModel(val titleId: String) { private var basePath: String private var updateJsonName = "updates.json" private var storageHelper: SimpleStorageHelper + var currentPaths: MutableList = mutableListOf() var pathsState: SnapshotStateList? = null companion object { const val UpdateRequestCode = 1002 } - fun Remove(index: Int) { + fun remove(index: Int) { if (index <= 0) return data?.paths?.apply { - val removed = removeAt(index - 1) - File(removed).deleteRecursively() + val str = removeAt(index - 1) + Uri.parse(str)?.apply { + storageHelper.storage.context.contentResolver.releasePersistableUriPermission( + this, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } pathsState?.clear() pathsState?.addAll(this) + currentPaths = this } } - fun Add( - isCopying: MutableState, - copyProgress: MutableState, - currentProgressName: MutableState - ) { + fun add() { val callBack = storageHelper.onFileSelected storageHelper.onFileSelected = { requestCode, files -> @@ -47,29 +53,29 @@ class TitleUpdateViewModel(val titleId: String) { if (requestCode == UpdateRequestCode) { val file = files.firstOrNull() file?.apply { - // Copy updates to internal data folder - val updatePath = "$basePath/update" - File(updatePath).mkdirs() - Helpers.copyToData( - this, - updatePath, - storageHelper, - isCopying, - copyProgress, - currentProgressName, ::refreshPaths - ) + if(file.extension == "nsp"){ + storageHelper.storage.context.contentResolver.takePersistableUriPermission(file.uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + currentPaths.add(file.uri.toString()) + } } + + refreshPaths() } } } storageHelper.openFilePicker(UpdateRequestCode) } - fun refreshPaths() { + private fun refreshPaths() { data?.apply { - val updatePath = "$basePath/update" val existingPaths = mutableListOf() - File(updatePath).listFiles()?.forEach { existingPaths.add(it.absolutePath) } + currentPaths.forEach { + val uri = Uri.parse(it) + val file = DocumentFile.fromSingleUri(storageHelper.storage.context, uri) + if(file?.exists() == true){ + existingPaths.add(it) + } + } if (!existingPaths.contains(selected)) { selected = "" @@ -88,7 +94,6 @@ class TitleUpdateViewModel(val titleId: String) { openDialog: MutableState ) { data?.apply { - val updatePath = "$basePath/update" this.selected = "" if (paths.isNotEmpty() && index > 0) { val ind = max(index - 1, paths.count() - 1) @@ -98,18 +103,29 @@ class TitleUpdateViewModel(val titleId: String) { File(basePath).mkdirs() - var metadata = TitleUpdateMetadata() + val metadata = TitleUpdateMetadata() val savedUpdates = mutableListOf() - File(updatePath).listFiles()?.forEach { savedUpdates.add(it.absolutePath) } + currentPaths.forEach { + val uri = Uri.parse(it) + val file = DocumentFile.fromSingleUri(storageHelper.storage.context, uri) + if(file?.exists() == true){ + savedUpdates.add(it) + } + } metadata.paths = savedUpdates - val selectedName = File(selected).name - val newSelectedPath = "$updatePath/$selectedName" - if (File(newSelectedPath).exists()) { - metadata.selected = newSelectedPath + if(selected.isNotEmpty()){ + val uri = Uri.parse(selected) + val file = DocumentFile.fromSingleUri(storageHelper.storage.context, uri) + if(file?.exists() == true){ + metadata.selected = selected + } + } + else { + metadata.selected = selected } - var json = gson.toJson(metadata) + val json = gson.toJson(metadata) File("$basePath/$updateJsonName").writeText(json) openDialog.value = false @@ -138,9 +154,12 @@ class TitleUpdateViewModel(val titleId: String) { data = gson.fromJson(File(jsonPath).readText(), TitleUpdateMetadata::class.java) } + currentPaths = data?.paths ?: mutableListOf() + storageHelper = MainActivity.StorageHelper!! refreshPaths() - storageHelper = MainActivity.StorageHelper!! + File("$basePath/update").deleteRecursively() + } } diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/TitleUpdateViews.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/TitleUpdateViews.kt index 719911db1..7eb8d62cf 100644 --- a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/TitleUpdateViews.kt +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/TitleUpdateViews.kt @@ -1,5 +1,6 @@ package org.ryujinx.android.views +import android.net.Uri import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -13,7 +14,6 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Surface @@ -28,13 +28,19 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.documentfile.provider.DocumentFile +import org.ryujinx.android.MainActivity import org.ryujinx.android.viewmodels.TitleUpdateViewModel -import java.io.File class TitleUpdateViews { companion object { @Composable - fun Main(titleId: String, name: String, openDialog: MutableState, canClose: MutableState) { + fun Main( + titleId: String, + name: String, + openDialog: MutableState, + canClose: MutableState + ) { val viewModel = TitleUpdateViewModel(titleId) val selected = remember { mutableStateOf(0) } @@ -43,15 +49,6 @@ class TitleUpdateViews { } Column(modifier = Modifier.padding(16.dp)) { - val isCopying = remember { - mutableStateOf(false) - } - val copyProgress = remember { - mutableStateOf(0.0f) - } - var currentProgressName = remember { - mutableStateOf("Starting Copy") - } Column { Text(text = "Updates for ${name}", textAlign = TextAlign.Center) Surface( @@ -88,18 +85,24 @@ class TitleUpdateViews { var index = 1 for (path in paths) { val i = index - Row(modifier = Modifier.padding(8.dp)) { - RadioButton( - selected = (selected.value == i), - onClick = { selected.value = i }) - Text( - text = File(path).name, - modifier = Modifier - .fillMaxWidth() - .align(Alignment.CenterVertically) - ) + val uri = Uri.parse(path) + val file = DocumentFile.fromSingleUri( + MainActivity.mainViewModel!!.activity, + uri + ) + file?.apply { + Row(modifier = Modifier.padding(8.dp)) { + RadioButton( + selected = (selected.value == i), + onClick = { selected.value = i }) + Text( + text = file.name ?: "", + modifier = Modifier + .fillMaxWidth() + .align(Alignment.CenterVertically) + ) + } } - index++ } } @@ -107,7 +110,7 @@ class TitleUpdateViews { Row(modifier = Modifier.align(Alignment.End)) { IconButton( onClick = { - viewModel.Remove(selected.value) + viewModel.remove(selected.value) } ) { Icon( @@ -118,7 +121,7 @@ class TitleUpdateViews { IconButton( onClick = { - viewModel.Add(isCopying, copyProgress, currentProgressName) + viewModel.add() } ) { Icon( @@ -129,33 +132,12 @@ class TitleUpdateViews { } } - if (isCopying.value) { - Text(text = "Copying updates to local storage") - Text(text = currentProgressName.value) - Row { - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth(), - progress = copyProgress.value - ) - TextButton( - onClick = { - isCopying.value = false - canClose.value = true - viewModel.refreshPaths() - }, - ) { - Text("Cancel") - } - } - } Spacer(modifier = Modifier.height(18.dp)) TextButton( modifier = Modifier.align(Alignment.End), onClick = { - if (!isCopying.value) { - canClose.value = true - viewModel.save(selected.value, openDialog) - } + canClose.value = true + viewModel.save(selected.value, openDialog) }, ) { Text("Save")