diff --git a/app/src/main/feature/sync/google/GameSaveBackupManager.kt b/app/src/main/feature/sync/google/GameSaveBackupManager.kt index d45535755..1a6b53f66 100644 --- a/app/src/main/feature/sync/google/GameSaveBackupManager.kt +++ b/app/src/main/feature/sync/google/GameSaveBackupManager.kt @@ -140,7 +140,7 @@ object GameSaveBackupManager { const val MAX_HISTORY_LABEL_LENGTH = 48 /** Custom-game extra-data keys persisted on the Shortcut. */ - const val CUSTOM_SAVE_CONTAINER_ID_KEY = "customSaveContainerId" + const val CUSTOM_LOCAL_SAVE_PATH_KEY = "customLocalSavePath" const val CUSTOM_SAVE_WINDOWS_PATH_KEY = "customSaveWindowsPath" /** Legacy key — an Android absolute path to a single custom-game folder. */ @@ -594,14 +594,22 @@ object GameSaveBackupManager { // ── Custom-game helpers (used by the "Select Save Folder" picker UI) ── - fun setCustomGameSavePath(shortcut: Shortcut, container: Container, windowsPath: String) { - shortcut.putExtra(CUSTOM_SAVE_CONTAINER_ID_KEY, container.id.toString()) + fun setCustomGameSavePath(shortcut: Shortcut, windowsPath: String) { shortcut.putExtra(CUSTOM_SAVE_WINDOWS_PATH_KEY, windowsPath) shortcut.saveData() } + fun setCustomLocalGameSavePath(shortcut: Shortcut, localPath: String) { + shortcut.putExtra(CUSTOM_LOCAL_SAVE_PATH_KEY, localPath) + shortcut.saveData() + } + + fun clearCustomLocalGameSavePath(shortcut: Shortcut) { + shortcut.putExtra(CUSTOM_LOCAL_SAVE_PATH_KEY, null) + shortcut.saveData() + } + fun clearCustomGameSavePath(shortcut: Shortcut) { - shortcut.putExtra(CUSTOM_SAVE_CONTAINER_ID_KEY, null) shortcut.putExtra(CUSTOM_SAVE_WINDOWS_PATH_KEY, null) shortcut.saveData() } @@ -609,6 +617,9 @@ object GameSaveBackupManager { fun getCustomGameSaveWindowsPath(shortcut: Shortcut): String? = shortcut.getExtra(CUSTOM_SAVE_WINDOWS_PATH_KEY)?.takeIf { it.isNotEmpty() } + fun getCustomLocalGameSavePath(shortcut: Shortcut): String? = + shortcut.getExtra(CUSTOM_LOCAL_SAVE_PATH_KEY)?.takeIf { it.isNotEmpty() } + /** Build the `gameId` token used for custom games when calling the public backup API. */ fun customGameId(shortcut: Shortcut): String { val containerId = shortcut.container?.id?.toString() ?: "0" diff --git a/app/src/main/feature/sync/ui/CloudSavesUIContent.kt b/app/src/main/feature/sync/ui/CloudSavesUIContent.kt index 1a42c91e8..291510d74 100644 --- a/app/src/main/feature/sync/ui/CloudSavesUIContent.kt +++ b/app/src/main/feature/sync/ui/CloudSavesUIContent.kt @@ -1,6 +1,7 @@ package com.winlator.cmod.feature.sync.ui import android.app.Activity +import android.os.Environment import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -10,6 +11,7 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement @@ -392,39 +394,112 @@ internal fun CloudSavesContent( } if (!steamManagedCloud) { - var customSavePath by remember(shortcut?.file?.absolutePath, historyRefreshKey) { + var showDeleteDialog by remember { mutableStateOf(false) }; + var customGameSavePath by remember(shortcut?.file?.absolutePath, historyRefreshKey) { mutableStateOf(shortcut?.let { GameSaveBackupManager.getCustomGameSaveWindowsPath(it) }) } + var customLocalGameSavePath by remember(shortcut?.file?.absolutePath, historyRefreshKey) { + mutableStateOf(shortcut?.let { GameSaveBackupManager.getCustomLocalGameSavePath(it) }) + } val customNoContainer = stringResource(R.string.cloud_saves_custom_no_container) val customPickerTitle = stringResource(R.string.cloud_saves_custom_picker_title) val customOutsideDriveC = stringResource(R.string.cloud_saves_custom_outside_drive_c) val customPathMapFailed = stringResource(R.string.cloud_saves_custom_path_map_failed) val gogManageNoBrowser = stringResource(R.string.cloud_saves_gog_manage_no_browser) + + val deletionDialog: @Composable () -> Unit = { + if (showDeleteDialog && !customGameSavePath.isNullOrEmpty()) { + Dialog( + onDismissRequest = { showDeleteDialog = false }, + properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true), + ) { + Box( + modifier = + Modifier + .width(320.dp) + .clip(RoundedCornerShape(20.dp)) + .background(SurfaceDark) + .border(1.dp, Accent.copy(alpha = 0.3f), RoundedCornerShape(20.dp)) + .padding(28.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = stringResource(R.string.common_ui_are_you_sure), + style = MaterialTheme.typography.titleLarge, + color = TextPrimary, + fontWeight = FontWeight.Bold, + ) + Text( + text = customGameSavePath.toString(), + style = MaterialTheme.typography.bodyMedium, + color = TextPrimary + ) + Spacer(Modifier.height(24.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + androidx.compose.material3.OutlinedButton( + onClick = { showDeleteDialog = false }, + colors = androidx.compose.material3.ButtonDefaults.outlinedButtonColors(contentColor = TextSecondary), + border = androidx.compose.foundation.BorderStroke(1.dp, TextSecondary.copy(alpha = 0.5f)), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.common_ui_cancel), fontWeight = FontWeight.Medium) + } + androidx.compose.material3.Button( + onClick = { + showDeleteDialog = false + if (shortcut == null || customGameSavePath.isNullOrEmpty()) return@Button + val androidPath = WinePathUtils.windowsToAndroidFile(customGameSavePath.toString(), shortcut.container) + try { + org.apache.commons.io.FileUtils.deleteDirectory(androidPath) + notify(context.getString(R.string.common_ui_done), Toast.LENGTH_SHORT) + } catch (e: Exception) { + notify( + String.format(context.getString(R.string.common_ui_error_with_message), e.message), + Toast.LENGTH_LONG + ) + } + }, + colors = androidx.compose.material3.ButtonDefaults.buttonColors(containerColor = Color(0xFFE53935)), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.common_ui_delete), color = Color.White, fontWeight = FontWeight.Bold) + } + } + } + } + } + } + } + val firstAction: @Composable (Modifier) -> Unit = { mod -> if (gameSource == GameSaveBackupManager.GameSource.CUSTOM) { - val pickerLabel = - if (customSavePath.isNullOrEmpty()) { - stringResource(R.string.cloud_saves_custom_select_folder) - } else { - stringResource(R.string.cloud_saves_custom_folder_label) - } - val pickerHelper = - if (customSavePath.isNullOrEmpty()) { - stringResource(R.string.cloud_saves_custom_select_folder_summary) - } else { - customSavePath.orEmpty() - } + var gameSavePickerLabel = stringResource(R.string.cloud_saves_custom_folder_label) + var gameSavePickerHelper = customGameSavePath.orEmpty() + var localSavePickerLabel = stringResource(R.string.local_saves_custom_folder_label) + var localSavePickerHelper = customLocalGameSavePath.orEmpty() + if (customGameSavePath.isNullOrEmpty()){ + gameSavePickerLabel = stringResource(R.string.cloud_saves_custom_select_folder) + gameSavePickerHelper = stringResource(R.string.cloud_saves_custom_select_folder_summary) + } + if (customLocalGameSavePath.isNullOrEmpty()) { + localSavePickerLabel = stringResource(R.string.local_saves_custom_select_folder_label) + localSavePickerHelper = stringResource(R.string.local_saves_custom_folder_summary) + } ActionWithHelper( icon = Icons.Outlined.FolderOpen, - label = pickerLabel, - helper = pickerHelper, + label = gameSavePickerLabel, + helper = gameSavePickerHelper, tint = CloudWarning, modifier = mod, enabled = !isWorking, onClick = { - val sc = shortcut - val container = sc?.container - if (sc == null || container == null) { + val container = shortcut?.container + if (shortcut == null || container == null) { notify( customNoContainer, Toast.LENGTH_SHORT, @@ -455,14 +530,81 @@ internal fun CloudSavesContent( ) return@show } - GameSaveBackupManager.setCustomGameSavePath(sc, container, winPath) - customSavePath = winPath + GameSaveBackupManager.setCustomGameSavePath(shortcut, winPath) + customGameSavePath = winPath notify( context.getString(R.string.cloud_saves_custom_folder_set, winPath), Toast.LENGTH_SHORT, ) } }, + onLongClick = { + shortcut?.let { + GameSaveBackupManager.clearCustomGameSavePath(shortcut) + customGameSavePath = null + notify( + context.getString(R.string.common_ui_reset), + Toast.LENGTH_SHORT, + ) + } + }, + onDoubleClick = { + if (!customGameSavePath.isNullOrEmpty()) + showDeleteDialog = true; + } + ) + + if (showDeleteDialog) + deletionDialog() + + ActionWithHelper( + icon = Icons.Outlined.FolderOpen, + label = localSavePickerLabel, + helper = localSavePickerHelper, + tint = CloudWarning, + modifier = mod, + enabled = !isWorking, + + onClick = { + if (customGameSavePath == null) { + notify( + context.getString(R.string.local_saves_must_select_game_folder_first), + Toast.LENGTH_SHORT + ) + return@ActionWithHelper + } + val container = shortcut?.container + if (shortcut == null || container == null) { + notify( + customNoContainer, + Toast.LENGTH_SHORT, + ) + return@ActionWithHelper + } + DirectoryPickerDialog.show( + activity, + initialPath = Environment.getExternalStorageDirectory().absolutePath, + title = customPickerTitle, + ) { picked -> + val directoryPicked = java.io.File(picked) + GameSaveBackupManager.setCustomLocalGameSavePath(shortcut, directoryPicked.absolutePath) + customLocalGameSavePath = directoryPicked.absolutePath + notify( + context.getString(R.string.cloud_saves_custom_folder_set, picked), + Toast.LENGTH_SHORT, + ) + } + }, + onLongClick = { + shortcut?.let { + GameSaveBackupManager.clearCustomLocalGameSavePath(shortcut) + customLocalGameSavePath = null + notify( + context.getString(R.string.common_ui_reset), + Toast.LENGTH_SHORT, + ) + } + }, ) } else { ActionWithHelper( @@ -1603,6 +1745,8 @@ private fun ActionWithHelper( enabled: Boolean = true, isEntry: Boolean = false, onClick: () -> Unit, + onLongClick:(() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, ) { val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() @@ -1623,12 +1767,15 @@ private fun ActionWithHelper( .graphicsLayer { scaleX = scale scaleY = scale - }.clip(RoundedCornerShape(8.dp)) - .clickable( + } + .clip(RoundedCornerShape(8.dp)) + .combinedClickable( interactionSource = interactionSource, indication = null, enabled = enabled, onClick = onClick, + onLongClick = onLongClick, + onDoubleClick = onDoubleClick ), color = if (enabled) CloudPanelRaised else CloudPanel, shape = RoundedCornerShape(8.dp), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5b95dd2b4..7e2342fb0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -58,6 +58,7 @@ Reset Close Confirm + Delete Unnamed Download Uninstall @@ -136,6 +137,7 @@ Not configured Reinstall Exit WinNative? + Are your sure? %1$d elements -- Disabled -- Enabled @@ -1401,6 +1403,14 @@ Installed path: Could not map the selected folder to a Windows path. Save folder set to %1$s + + + Local Folder + Select Local Folder + Local folder where your game saves will copied to. + A game save folder must be selected first. + + Sync from Steam Cloud Download latest save diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index c14b4f98d..d561df00b 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -62,6 +62,7 @@ import com.winlator.cmod.app.update.UpdateChecker; import com.winlator.cmod.feature.settings.DebugFragment; import com.winlator.cmod.feature.setup.SetupWizardActivity; +import com.winlator.cmod.feature.sync.google.WinePathUtils; import com.winlator.cmod.runtime.container.Container; import com.winlator.cmod.runtime.container.ContainerManager; import com.winlator.cmod.runtime.container.Shortcut; @@ -1691,6 +1692,17 @@ this, shortcut, isCloudSyncEnabledForShortcut(), GogLaunchCloudSync.syncBeforeLaunch( this, shortcut, isCloudSyncEnabledForShortcut(), this::showLaunchPreloader); + if (isCustomShortcut()) { + String winePath = shortcut.getExtra("customSaveWindowsPath"); + String localPath = shortcut.getExtra("customLocalSavePath"); + if (!localPath.isEmpty() && !winePath.isEmpty()) { + File wineDirectory = WinePathUtils.INSTANCE.windowsToAndroidFile(winePath, container); + File localDirectory = new File(localPath); + if (localDirectory.isDirectory()) { + FileUtils.syncDirectories(localDirectory, wineDirectory, false); + } + } + } } catch (Throwable t) { Log.w("XServerDisplayActivity", "Pre-launch cloud sync failed", t); @@ -3025,6 +3037,18 @@ private void exit() { return; } + if (isCustomShortcut() && shortcut != null) { + String winePath = shortcut.getExtra("customSaveWindowsPath"); + String localPath = shortcut.getExtra("customLocalSavePath"); + if (!localPath.isEmpty() && !winePath.isEmpty()) { + File wineDirectory = WinePathUtils.INSTANCE.windowsToAndroidFile(winePath, container); + File localDirectory = new File(localPath); + if (wineDirectory.isDirectory()) { + FileUtils.syncDirectories(wineDirectory, localDirectory, true); + } + } + } + if (shortcutName != null && !shortcutName.isEmpty()) { preloaderDialog.showOnUiThread(getString(R.string.preloader_closing, shortcutName)); } else { diff --git a/app/src/main/shared/io/FileUtils.java b/app/src/main/shared/io/FileUtils.java index 7d90746f0..e982e6ce3 100644 --- a/app/src/main/shared/io/FileUtils.java +++ b/app/src/main/shared/io/FileUtils.java @@ -618,6 +618,57 @@ public static String getFileSuffix(String path) { } } + public static int syncDirectories(File source, File destination, boolean propagateDeletions) { + // Thanks to abduznik on Github. This is pretty much a rewrite of his sync function in abduznik/OmniSave from c to java. + if (!destination.isDirectory()) + if (!destination.mkdirs()) return 0; + int allSuccess = 1; + File[] sourceFiles = source.listFiles(); + if (sourceFiles == null) + return 0; + + for (File file : sourceFiles) { + File destinationFile = new File(String.format("%s/%s", destination.getAbsolutePath(), file.getName())); + if (file.isDirectory()) { + allSuccess = syncDirectories(file, destinationFile, propagateDeletions); + } else { + if (!destinationFile.isFile() || file.lastModified() <= destinationFile.lastModified()) { + try { + Files.copy(file.toPath(), destinationFile.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } catch (Exception e) { + allSuccess = 0; + } + } + } + } + if (!propagateDeletions) + return allSuccess; + File[] destinationFiles = destination.listFiles(); + if (destinationFiles == null) + return 0; + File sourceFile; + File destinationFile; + boolean shouldNotDeleteFile; + for (File file : destinationFiles) { + sourceFile = new File(String.format("%s/%s", source.getAbsolutePath(), file.getName())); + destinationFile = new File(String.format("%s/%s", destination.getAbsolutePath(), file.getName())); + shouldNotDeleteFile = destinationFile.isDirectory() ? sourceFile.isDirectory() : sourceFile.isFile(); + if (shouldNotDeleteFile) + continue; + + if (destinationFile.isDirectory()) { + try { + org.apache.commons.io.FileUtils.deleteDirectory(destinationFile); + }catch(Exception e) { + allSuccess = 0; + } + } + if (!destinationFile.delete()) + allSuccess = 0; + } + return allSuccess; + } + public static File getFileFromUri(Context context, Uri uri) { Log.d(TAG, "getFileFromUri called with URI: " + uri.toString());