From ea2252073327507987d968f04c6dd5b4063d19d7 Mon Sep 17 00:00:00 2001 From: Sad Moments Date: Mon, 20 Jul 2026 20:54:01 +0200 Subject: [PATCH] Feature: add local folder saving. This adds local folder saving to WinNative, what does this mean is you select a custom game save folder and a local folder this will sync the folder from src -> dst. example: On start of a game will check if the src has newer files then the dst and will copy them if they exist and on shutdown it does the same but will also delete any folder or files that exist on dst but not on source, on start src is your local folder and on shutdown it's the wine path inside windows. --- .../sync/google/GameSaveBackupManager.kt | 19 +- .../feature/sync/ui/CloudSavesUIContent.kt | 191 ++++++++++++++++-- app/src/main/res/values/strings.xml | 10 + .../display/XServerDisplayActivity.java | 24 +++ app/src/main/shared/io/FileUtils.java | 51 +++++ 5 files changed, 269 insertions(+), 26 deletions(-) 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());