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")] [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"); Logger.Trace?.Print(LogClass.Application, "Jni Function Call");
if (SwitchDevice?.EmulationContext == null) if (SwitchDevice?.EmulationContext == null)
@ -271,8 +271,9 @@ namespace LibRyujinx
} }
var stream = OpenFile(descriptor); 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")] [UnmanagedCallersOnly(EntryPoint = "Java_org_ryujinx_android_RyujinxNative_deviceVerifyFirmware")]

View File

@ -77,14 +77,14 @@ namespace LibRyujinx
return SwitchDevice?.ContentManager?.VerifyFirmwarePackage(stream, isXci) ?? null; 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; var emulationContext = SwitchDevice.EmulationContext;
return type switch return type switch
{ {
FileType.None => false, FileType.None => false,
FileType.Nsp => emulationContext?.LoadNsp(stream) ?? false, FileType.Nsp => emulationContext?.LoadNsp(stream, updateStream) ?? false,
FileType.Xci => emulationContext?.LoadXci(stream) ?? false, FileType.Xci => emulationContext?.LoadXci(stream, updateStream) ?? false,
FileType.Nro => emulationContext?.LoadProgram(stream, true, "") ?? 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 DownloadableContentJsonSerializerContext _contentSerializerContext = new(JsonHelper.GetDefaultSerializerOptions());
private static readonly TitleUpdateMetadataJsonSerializerContext _titleSerializerContext = 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 TMetaData : PartitionFileSystemMetaCore<TFormat, THeader, TEntry>, new()
where TFormat : IPartitionFileSystemFormat where TFormat : IPartitionFileSystemFormat
where THeader : unmanaged, IPartitionFileSystemHeader where THeader : unmanaged, IPartitionFileSystemHeader
@ -87,42 +87,21 @@ namespace Ryujinx.HLE.Loaders.Processes.Extensions
{ {
// Clear the program index part. // Clear the program index part.
titleIdBase &= ~0xFUL; titleIdBase &= ~0xFUL;
PartitionFileSystem updatePartitionFileSystem = new();
// Load update information if exists. if (updateStream != null)
string titleUpdateMetadataPath = System.IO.Path.Combine(AppDataManager.GamesDirPath, titleIdBase.ToString("x16"), "updates.json");
if (File.Exists(titleUpdateMetadataPath))
{ {
string updatePath = JsonHelper.DeserializeFromFile(titleUpdateMetadataPath, _titleSerializerContext.TitleUpdateMetadata).Selected; LoadUpdate(device, updateStream, ref updatePatchNca, ref updateControlNca, titleIdBase, updatePartitionFileSystem);
if (File.Exists(updatePath)) }
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(); string updatePath = JsonHelper.DeserializeFromFile(titleUpdateMetadataPath, _titleSerializerContext.TitleUpdateMetadata).Selected;
updatePartitionFileSystem.Initialize(new FileStream(updatePath, FileMode.Open, FileAccess.Read).AsStorage()).ThrowIfFailure(); if (File.Exists(updatePath))
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); LoadUpdate(device, new FileStream(updatePath, FileMode.Open, FileAccess.Read), ref updatePatchNca, ref updateControlNca, titleIdBase, updatePartitionFileSystem);
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;
}
} }
} }
} }
@ -171,6 +150,38 @@ namespace Ryujinx.HLE.Loaders.Processes.Extensions
errorMessage = "Unable to load: Could not find Main NCA"; errorMessage = "Unable to load: Could not find Main NCA";
return (false, ProcessResult.Failed); 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) 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); 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()); Xci xci = new(_device.Configuration.VirtualFileSystem.KeySet, stream.AsStorage());
@ -50,7 +50,7 @@ namespace Ryujinx.HLE.Loaders.Processes
return false; 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) if (!success)
{ {
@ -79,12 +79,12 @@ namespace Ryujinx.HLE.Loaders.Processes
return LoadNsp(file); return LoadNsp(file);
} }
public bool LoadNsp(Stream stream) public bool LoadNsp(Stream stream, Stream updateStream = null)
{ {
PartitionFileSystem partitionFileSystem = new(); PartitionFileSystem partitionFileSystem = new();
partitionFileSystem.Initialize(stream.AsStorage()).ThrowIfFailure(); 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) if (processResult.ProcessId == 0)
{ {

View File

@ -93,7 +93,7 @@ namespace Ryujinx.HLE
return Processes.LoadNxo(fileName); return Processes.LoadNxo(fileName);
} }
public bool LoadXci(Stream xciStream) public bool LoadXci(Stream xciStream, Stream updateStream = null)
{ {
return Processes.LoadXci(xciStream); return Processes.LoadXci(xciStream);
} }
@ -103,9 +103,9 @@ namespace Ryujinx.HLE
return Processes.LoadNca(ncaStream); 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) public bool LoadProgram(Stream stream, bool isNro, string name)

View File

@ -39,7 +39,7 @@ class RyujinxNative {
external fun deviceGetGameFifo(): Double external fun deviceGetGameFifo(): Double
external fun deviceGetGameInfo(fileDescriptor: Int, extension: Long): GameInfo external fun deviceGetGameInfo(fileDescriptor: Int, extension: Long): GameInfo
external fun deviceGetGameInfoFromPath(path: String): 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 graphicsRendererSetSize(width: Int, height: Int)
external fun graphicsRendererSetVsync(enabled: Boolean) external fun graphicsRendererSetVsync(enabled: Boolean)
external fun graphicsRendererRunLoop() external fun graphicsRendererRunLoop()

View File

@ -1,6 +1,7 @@
package org.ryujinx.android.viewmodels package org.ryujinx.android.viewmodels
import android.content.Context import android.content.Context
import android.net.Uri
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.file.extension import com.anggrayudi.storage.file.extension
@ -9,6 +10,7 @@ import org.ryujinx.android.RyujinxNative
class GameModel(var file: DocumentFile, val context: Context) { class GameModel(var file: DocumentFile, val context: Context) {
private var updateDescriptor: ParcelFileDescriptor? = null
var type: FileType var type: FileType
var descriptor: ParcelFileDescriptor? = null var descriptor: ParcelFileDescriptor? = null
var fileName: String? var fileName: String?
@ -50,9 +52,29 @@ class GameModel(var file: DocumentFile, val context: Context) {
return descriptor?.fd ?: 0 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() { fun close() {
descriptor?.close() descriptor?.close()
descriptor = null descriptor = null
updateDescriptor?.close()
updateDescriptor = null
} }
} }

View File

@ -85,6 +85,8 @@ class MainViewModel(val activity: MainActivity) {
if (descriptor == 0) if (descriptor == 0)
return false return false
val update = game.openUpdate()
gameModel = game gameModel = game
isMiiEditorLaunched = false isMiiEditorLaunched = false
@ -178,7 +180,7 @@ class MainViewModel(val activity: MainActivity) {
if (!success) if (!success)
return false return false
success = nativeRyujinx.deviceLoadDescriptor(descriptor, game.type.ordinal) success = nativeRyujinx.deviceLoadDescriptor(descriptor, game.type.ordinal, update)
if (!success) if (!success)
return false return false

View File

@ -1,12 +1,15 @@
package org.ryujinx.android.viewmodels package org.ryujinx.android.viewmodels
import android.content.Intent
import android.net.Uri
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.toLowerCase import androidx.compose.ui.text.toLowerCase
import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.SimpleStorageHelper import com.anggrayudi.storage.SimpleStorageHelper
import com.anggrayudi.storage.file.extension
import com.google.gson.Gson import com.google.gson.Gson
import org.ryujinx.android.Helpers
import org.ryujinx.android.MainActivity import org.ryujinx.android.MainActivity
import java.io.File import java.io.File
import kotlin.math.max import kotlin.math.max
@ -16,29 +19,32 @@ class TitleUpdateViewModel(val titleId: String) {
private var basePath: String private var basePath: String
private var updateJsonName = "updates.json" private var updateJsonName = "updates.json"
private var storageHelper: SimpleStorageHelper private var storageHelper: SimpleStorageHelper
var currentPaths: MutableList<String> = mutableListOf()
var pathsState: SnapshotStateList<String>? = null var pathsState: SnapshotStateList<String>? = null
companion object { companion object {
const val UpdateRequestCode = 1002 const val UpdateRequestCode = 1002
} }
fun Remove(index: Int) { fun remove(index: Int) {
if (index <= 0) if (index <= 0)
return return
data?.paths?.apply { data?.paths?.apply {
val removed = removeAt(index - 1) val str = removeAt(index - 1)
File(removed).deleteRecursively() Uri.parse(str)?.apply {
storageHelper.storage.context.contentResolver.releasePersistableUriPermission(
this,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
pathsState?.clear() pathsState?.clear()
pathsState?.addAll(this) pathsState?.addAll(this)
currentPaths = this
} }
} }
fun Add( fun add() {
isCopying: MutableState<Boolean>,
copyProgress: MutableState<Float>,
currentProgressName: MutableState<String>
) {
val callBack = storageHelper.onFileSelected val callBack = storageHelper.onFileSelected
storageHelper.onFileSelected = { requestCode, files -> storageHelper.onFileSelected = { requestCode, files ->
@ -47,29 +53,29 @@ class TitleUpdateViewModel(val titleId: String) {
if (requestCode == UpdateRequestCode) { if (requestCode == UpdateRequestCode) {
val file = files.firstOrNull() val file = files.firstOrNull()
file?.apply { file?.apply {
// Copy updates to internal data folder if(file.extension == "nsp"){
val updatePath = "$basePath/update" storageHelper.storage.context.contentResolver.takePersistableUriPermission(file.uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
File(updatePath).mkdirs() currentPaths.add(file.uri.toString())
Helpers.copyToData( }
this,
updatePath,
storageHelper,
isCopying,
copyProgress,
currentProgressName, ::refreshPaths
)
} }
refreshPaths()
} }
} }
} }
storageHelper.openFilePicker(UpdateRequestCode) storageHelper.openFilePicker(UpdateRequestCode)
} }
fun refreshPaths() { private fun refreshPaths() {
data?.apply { data?.apply {
val updatePath = "$basePath/update"
val existingPaths = mutableListOf<String>() 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)) { if (!existingPaths.contains(selected)) {
selected = "" selected = ""
@ -88,7 +94,6 @@ class TitleUpdateViewModel(val titleId: String) {
openDialog: MutableState<Boolean> openDialog: MutableState<Boolean>
) { ) {
data?.apply { data?.apply {
val updatePath = "$basePath/update"
this.selected = "" this.selected = ""
if (paths.isNotEmpty() && index > 0) { if (paths.isNotEmpty() && index > 0) {
val ind = max(index - 1, paths.count() - 1) val ind = max(index - 1, paths.count() - 1)
@ -98,18 +103,29 @@ class TitleUpdateViewModel(val titleId: String) {
File(basePath).mkdirs() File(basePath).mkdirs()
var metadata = TitleUpdateMetadata() val metadata = TitleUpdateMetadata()
val savedUpdates = mutableListOf<String>() 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 metadata.paths = savedUpdates
val selectedName = File(selected).name if(selected.isNotEmpty()){
val newSelectedPath = "$updatePath/$selectedName" val uri = Uri.parse(selected)
if (File(newSelectedPath).exists()) { val file = DocumentFile.fromSingleUri(storageHelper.storage.context, uri)
metadata.selected = newSelectedPath 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) File("$basePath/$updateJsonName").writeText(json)
openDialog.value = false openDialog.value = false
@ -138,9 +154,12 @@ class TitleUpdateViewModel(val titleId: String) {
data = gson.fromJson(File(jsonPath).readText(), TitleUpdateMetadata::class.java) data = gson.fromJson(File(jsonPath).readText(), TitleUpdateMetadata::class.java)
} }
currentPaths = data?.paths ?: mutableListOf()
storageHelper = MainActivity.StorageHelper!!
refreshPaths() refreshPaths()
storageHelper = MainActivity.StorageHelper!! File("$basePath/update").deleteRecursively()
} }
} }

View File

@ -1,5 +1,6 @@
package org.ryujinx.android.views package org.ryujinx.android.views
import android.net.Uri
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.material.icons.filled.Delete
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@ -28,13 +28,19 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.documentfile.provider.DocumentFile
import org.ryujinx.android.MainActivity
import org.ryujinx.android.viewmodels.TitleUpdateViewModel import org.ryujinx.android.viewmodels.TitleUpdateViewModel
import java.io.File
class TitleUpdateViews { class TitleUpdateViews {
companion object { companion object {
@Composable @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 viewModel = TitleUpdateViewModel(titleId)
val selected = remember { mutableStateOf(0) } val selected = remember { mutableStateOf(0) }
@ -43,15 +49,6 @@ class TitleUpdateViews {
} }
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
val isCopying = remember {
mutableStateOf(false)
}
val copyProgress = remember {
mutableStateOf(0.0f)
}
var currentProgressName = remember {
mutableStateOf("Starting Copy")
}
Column { Column {
Text(text = "Updates for ${name}", textAlign = TextAlign.Center) Text(text = "Updates for ${name}", textAlign = TextAlign.Center)
Surface( Surface(
@ -88,18 +85,24 @@ class TitleUpdateViews {
var index = 1 var index = 1
for (path in paths) { for (path in paths) {
val i = index val i = index
Row(modifier = Modifier.padding(8.dp)) { val uri = Uri.parse(path)
RadioButton( val file = DocumentFile.fromSingleUri(
selected = (selected.value == i), MainActivity.mainViewModel!!.activity,
onClick = { selected.value = i }) uri
Text( )
text = File(path).name, file?.apply {
modifier = Modifier Row(modifier = Modifier.padding(8.dp)) {
.fillMaxWidth() RadioButton(
.align(Alignment.CenterVertically) selected = (selected.value == i),
) onClick = { selected.value = i })
Text(
text = file.name ?: "",
modifier = Modifier
.fillMaxWidth()
.align(Alignment.CenterVertically)
)
}
} }
index++ index++
} }
} }
@ -107,7 +110,7 @@ class TitleUpdateViews {
Row(modifier = Modifier.align(Alignment.End)) { Row(modifier = Modifier.align(Alignment.End)) {
IconButton( IconButton(
onClick = { onClick = {
viewModel.Remove(selected.value) viewModel.remove(selected.value)
} }
) { ) {
Icon( Icon(
@ -118,7 +121,7 @@ class TitleUpdateViews {
IconButton( IconButton(
onClick = { onClick = {
viewModel.Add(isCopying, copyProgress, currentProgressName) viewModel.add()
} }
) { ) {
Icon( 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)) Spacer(modifier = Modifier.height(18.dp))
TextButton( TextButton(
modifier = Modifier.align(Alignment.End), modifier = Modifier.align(Alignment.End),
onClick = { onClick = {
if (!isCopying.value) { canClose.value = true
canClose.value = true viewModel.save(selected.value, openDialog)
viewModel.save(selected.value, openDialog)
}
}, },
) { ) {
Text("Save") Text("Save")