Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions app/src/main/feature/sync/google/GameSaveBackupManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -594,21 +594,32 @@ 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()
}

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"
Expand Down
191 changes: 169 additions & 22 deletions app/src/main/feature/sync/ui/CloudSavesUIContent.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Expand All @@ -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),
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
<string name="common_ui_reset">Reset</string>
<string name="common_ui_close">Close</string>
<string name="common_ui_confirm">Confirm</string>
<string name="common_ui_delete">Delete</string>
<string name="common_ui_unnamed">Unnamed</string>
<string name="common_ui_download">Download</string>
<string name="common_ui_uninstall">Uninstall</string>
Expand Down Expand Up @@ -136,6 +137,7 @@
<string name="common_ui_not_configured">Not configured</string>
<string name="common_ui_reinstall">Reinstall</string>
<string name="common_ui_exit_app_confirm">Exit WinNative?</string>
<string name="common_ui_are_you_sure">Are your sure?</string>
<string name="common_ui_elements_count">%1$d elements</string>
<string name="common_ui_disabled_placeholder">-- Disabled --</string>
<string name="common_ui_enabled">Enabled</string>
Expand Down Expand Up @@ -1401,6 +1403,14 @@ Installed path:
<string name="cloud_saves_custom_path_map_failed">Could not map the selected folder to a Windows path.</string>
<string name="cloud_saves_custom_folder_set">Save folder set to %1$s</string>

<!-- Local Saves > Custom save folder picker -->

<string name="local_saves_custom_folder_label">Local Folder</string>
<string name="local_saves_custom_select_folder_label">Select Local Folder</string>
<string name="local_saves_custom_folder_summary">Local folder where your game saves will copied to.</string>
<string name="local_saves_must_select_game_folder_first">A game save folder must be selected first.</string>


<!-- Cloud Saves > Steam Cloud actions -->
<string name="cloud_saves_steam_sync_label">Sync from Steam Cloud</string>
<string name="cloud_saves_steam_sync_helper">Download latest save</string>
Expand Down
Loading