android - move title updates support to SAF

This commit is contained in:
Emmanuel Hansen 2023-12-30 12:23:07 +00:00
parent df99842106
commit a718af9dd1
10 changed files with 165 additions and 128 deletions

View File

@ -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")]

View File

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

View File

@ -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<TMetaData, TFormat, THeader, TEntry>(this PartitionFileSystemCore<TMetaData, TFormat, THeader, TEntry> partitionFileSystem, Switch device, Stream stream, out string errorMessage, string extension)
internal static (bool, ProcessResult) TryLoad<TMetaData, TFormat, THeader, TEntry>(this PartitionFileSystemCore<TMetaData, TFormat, THeader, TEntry> partitionFileSystem, Switch device, Stream stream, out string errorMessage, string extension, Stream updateStream = null)
where TMetaData : PartitionFileSystemMetaCore<TFormat, THeader, TEntry>, new()
where TFormat : IPartitionFileSystemFormat
where THeader : unmanaged, IPartitionFileSystemHeader
@ -87,7 +87,13 @@ namespace Ryujinx.HLE.Loaders.Processes.Extensions
{
// Clear the program index part.
titleIdBase &= ~0xFUL;
PartitionFileSystem updatePartitionFileSystem = new();
if (updateStream != null)
{
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))
@ -95,34 +101,7 @@ namespace Ryujinx.HLE.Loaders.Processes.Extensions
string updatePath = JsonHelper.DeserializeFromFile(titleUpdateMetadataPath, _titleSerializerContext.TitleUpdateMetadata).Selected;
if (File.Exists(updatePath))
{
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"))
{
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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<String> = mutableListOf()
var pathsState: SnapshotStateList<String>? = 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<Boolean>,
copyProgress: MutableState<Float>,
currentProgressName: MutableState<String>
) {
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<String>()
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<Boolean>
) {
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<String>()
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()
}
}

View File

@ -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<Boolean>, canClose: MutableState<Boolean>) {
fun Main(
titleId: String,
name: String,
openDialog: MutableState<Boolean>,
canClose: MutableState<Boolean>
) {
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
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(path).name,
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)
}
},
) {
Text("Save")