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