Skip to content
Draft
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
115 changes: 102 additions & 13 deletions app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ class GOGCloudSavesManager(
NONE
}

data class SyncCheckResult(
val action: SyncAction,
val localTimestampMs: Long,
val remoteTimestampMs: Long,
)

/**
* Represents a local save file
*/
Expand Down Expand Up @@ -94,6 +100,23 @@ class GOGCloudSavesManager(
Timber.e(e, "Failed to calculate metadata for $absolutePath")
}
}

suspend fun calculateTimestamp() = withContext(Dispatchers.IO) {
try {
val file = File(absolutePath)
if (!file.exists() || !file.isFile) {
Timber.w("File does not exist: $absolutePath")
return@withContext
}

val timestamp = file.lastModified()
val instant = Instant.ofEpochMilli(timestamp)
updateTime = DateTimeFormatter.ISO_INSTANT.format(instant)
updateTimestamp = timestamp / 1000
} catch (e: Exception) {
Timber.e(e, "Failed to read timestamp for $absolutePath")
}
}
}

/**
Expand Down Expand Up @@ -214,18 +237,33 @@ class GOGCloudSavesManager(
// Handle preferred action
if (preferredAction == "download" && downloadableCloud.isNotEmpty()) {
Timber.tag("GOG-CloudSaves").i("Forcing download of ${downloadableCloud.size} file(s) (user requested)")
downloadableCloud.forEach { file ->
downloadFile(credentials.userId, clientId, dirname, file, syncDir, credentials.accessToken)
val allDownloaded = downloadableCloud.fold(true) { allSucceeded, file ->
val downloaded = downloadFile(
credentials.userId,
clientId,
dirname,
file,
syncDir,
credentials.accessToken,
)
allSucceeded && downloaded
}
return@withContext currentTimestamp()
return@withContext if (allDownloaded) currentTimestamp() else 0L
}

if (preferredAction == "upload" && localFiles.isNotEmpty()) {
Timber.tag("GOG-CloudSaves").i("Forcing upload of ${localFiles.size} file(s) (user requested)")
localFiles.forEach { file ->
uploadFile(credentials.userId, clientId, dirname, file, credentials.accessToken)
val allUploaded = localFiles.fold(true) { allSucceeded, file ->
val uploaded = uploadFile(
credentials.userId,
clientId,
dirname,
file,
credentials.accessToken,
)
allSucceeded && uploaded
}
return@withContext currentTimestamp()
return@withContext if (allUploaded) currentTimestamp() else 0L
}
Comment on lines 238 to 267
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Forced Keep Local/Keep Remote aborts on first failure, leaving a partial sync.

.all { ... } short‑circuits: the moment a single downloadFile/uploadFile returns false, the remaining files are never attempted. With the new preLaunchApp behavior that surfaces SYNC_FAIL on a 0L return, a transient failure on file #1 means:

  • Files #2..#N are silently skipped even though they would likely have succeeded.
  • The user sees SYNC_FAIL and retries, but local/cloud state is already partially transferred (anything done before the failure persists), and the next retry may itself conflict because only a subset of files was propagated.

For an explicit user decision ("Keep Local" / "Keep Remote"), prefer best‑effort: attempt every file, then report overall success.

♻️ Proposed fix
             if (preferredAction == "download" && downloadableCloud.isNotEmpty()) {
                 Timber.tag("GOG-CloudSaves").i("Forcing download of ${downloadableCloud.size} file(s) (user requested)")
-                val allDownloaded = downloadableCloud.all { file ->
-                    downloadFile(credentials.userId, clientId, dirname, file, syncDir, credentials.accessToken)
-                }
+                val allDownloaded = downloadableCloud.fold(true) { acc, file ->
+                    val ok = downloadFile(credentials.userId, clientId, dirname, file, syncDir, credentials.accessToken)
+                    acc && ok
+                }
                 return@withContext if (allDownloaded) currentTimestamp() else 0L
             }

             if (preferredAction == "upload" && localFiles.isNotEmpty()) {
                 Timber.tag("GOG-CloudSaves").i("Forcing upload of ${localFiles.size} file(s) (user requested)")
-                val allUploaded = localFiles.all { file ->
-                    uploadFile(credentials.userId, clientId, dirname, file, credentials.accessToken)
-                }
+                val allUploaded = localFiles.fold(true) { acc, file ->
+                    val ok = uploadFile(credentials.userId, clientId, dirname, file, credentials.accessToken)
+                    acc && ok
+                }
                 return@withContext if (allUploaded) currentTimestamp() else 0L
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt` around
lines 221 - 235, The forced "download"/"upload" branches currently use
downloadableCloud.all { ... } / localFiles.all { ... } which short‑circuits on
the first false and skips remaining files; change them to iterate over every
file (e.g., a for loop or forEach) calling downloadFile(...) / uploadFile(...)
for each item, track a cumulative success flag (initialize true and set to false
if any call returns false), optionally log per-file failures with
Timber.tag("GOG-CloudSaves"), and after the loop return currentTimestamp() if
the cumulative flag is true else 0L; this fixes preferredAction handling in the
block that references downloadableCloud, localFiles, downloadFile, uploadFile
and the withContext return.


// Complex sync scenario - use classifier
Expand Down Expand Up @@ -331,10 +369,51 @@ class GOGCloudSavesManager(
}
}

/**
* Read-only pre-flight check: returns the sync action and file timestamps in a single network
* round-trip. Returns null if credentials or cloud file listing are unavailable.
*/
suspend fun checkSync(
localPath: String,
dirname: String,
clientId: String,
clientSecret: String,
lastSyncTimestamp: Long = 0,
): SyncCheckResult? = withContext(Dispatchers.IO) {
try {
val credentials = GOGAuthManager.getGameCredentials(context, clientId, clientSecret)
.getOrNull() ?: return@withContext null

val syncDir = File(localPath)
val localFiles = if (syncDir.exists()) scanLocalFiles(syncDir, calculateHashes = false) else emptyList()
val cloudFiles = getCloudFiles(credentials.userId, clientId, dirname, credentials.accessToken)
?: return@withContext null

val action = when {
localFiles.isEmpty() && cloudFiles.isEmpty() -> SyncAction.NONE
localFiles.isNotEmpty() && cloudFiles.isEmpty() -> SyncAction.UPLOAD
localFiles.isEmpty() && cloudFiles.any { !it.isDeleted } -> SyncAction.DOWNLOAD
else -> classifyFiles(localFiles, cloudFiles, lastSyncTimestamp).determineAction()
}

val localMax = localFiles.maxOfOrNull { it.updateTimestamp ?: 0L } ?: 0L
val remoteMax = cloudFiles
.filter { !it.isDeleted }
.maxOfOrNull { it.updateTimestamp ?: 0L } ?: 0L
SyncCheckResult(action, localMax * 1000, remoteMax * 1000)
Comment on lines +399 to +403
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

remoteTimestampMs includes deletion tombstones.

cloudFiles still contains entries with md5Hash == DELETION_MD5; their last_modified contributes to remoteMax. The value flows into the SYNC_CONFLICT dialog as the remote save time, so if the most recent cloud entry is a tombstone the user sees a misleading "cloud save" timestamp that doesn't correspond to any real save payload. syncSaves already uses downloadableCloud (!isDeleted) for the analogous empty-cloud check — mirror that here.

♻️ Proposed fix
-            val localMax = localFiles.maxOfOrNull { it.updateTimestamp ?: 0L } ?: 0L
-            val remoteMax = cloudFiles.maxOfOrNull { it.updateTimestamp ?: 0L } ?: 0L
+            val localMax = localFiles.maxOfOrNull { it.updateTimestamp ?: 0L } ?: 0L
+            val remoteMax = cloudFiles
+                .filter { !it.isDeleted }
+                .maxOfOrNull { it.updateTimestamp ?: 0L } ?: 0L
             SyncCheckResult(action, localMax * 1000, remoteMax * 1000)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt` around
lines 367 - 369, The remoteMax currently includes deletion tombstones from
cloudFiles; update the computation so it ignores deleted cloud entries (those
with md5Hash == DELETION_MD5 or where isDeleted is true) when computing
remoteMax so the SyncCheckResult(remoteTimestampMs) reflects only real save
payloads — mirror the same filter used by syncSaves/downloadableCloud
(!isDeleted) before calling cloudFiles.maxOfOrNull and then pass remoteMax *
1000 into SyncCheckResult.

} catch (e: Exception) {
Timber.tag("GOG-CloudSaves").e(e, "checkSync failed")
null
}
}
Comment thread
kiequoo marked this conversation as resolved.

/**
* Scan local directory for save files
*/
private suspend fun scanLocalFiles(directory: File): List<SyncFile> = withContext(Dispatchers.IO) {
private suspend fun scanLocalFiles(
directory: File,
calculateHashes: Boolean = true,
): List<SyncFile> = withContext(Dispatchers.IO) {
val files = mutableListOf<SyncFile>()

fun scanRecursive(dir: File, basePath: String) {
Expand All @@ -352,8 +431,13 @@ class GOGCloudSavesManager(

scanRecursive(directory, directory.absolutePath)

// Calculate metadata for all files
files.forEach { it.calculateMetadata() }
files.forEach { file ->
if (calculateHashes) {
file.calculateMetadata()
} else {
file.calculateTimestamp()
}
}

files
}
Expand Down Expand Up @@ -467,7 +551,7 @@ class GOGCloudSavesManager(
dirname: String,
file: SyncFile,
authToken: String
) = withContext(Dispatchers.IO) {
): Boolean = withContext(Dispatchers.IO) {
try {
val localFile = File(file.absolutePath)
val fileSize = localFile.length()
Expand Down Expand Up @@ -495,15 +579,18 @@ class GOGCloudSavesManager(
response.use {
if (response.isSuccessful) {
Timber.tag("GOG-CloudSaves").i("Successfully uploaded: ${file.relativePath}")
true
} else {
val errorBody = response.body?.string() ?: "No response body"
Timber.tag("GOG-CloudSaves").e("Failed to upload ${file.relativePath}: HTTP ${response.code}")
Timber.tag("GOG-CloudSaves").e("Upload error body: $errorBody")
false
}
}

} catch (e: Exception) {
Timber.tag("GOG-CloudSaves").e(e, "Failed to upload ${file.relativePath}")
false
}
}

Expand All @@ -517,7 +604,7 @@ class GOGCloudSavesManager(
file: CloudFile,
syncDir: File,
authToken: String
) = withContext(Dispatchers.IO) {
): Boolean = withContext(Dispatchers.IO) {
try {
Timber.tag("GOG-CloudSaves").i("Downloading: ${file.relativePath}")

Expand All @@ -536,10 +623,10 @@ class GOGCloudSavesManager(
val errorBody = response.body?.string() ?: "No response body"
Timber.tag("GOG-CloudSaves").e("Failed to download ${file.relativePath}: HTTP ${response.code}")
Timber.tag("GOG-CloudSaves").e("Download error body: $errorBody")
return@withContext
return@withContext false
}

val bytes = response.body?.bytes() ?: return@withContext
val bytes = response.body?.bytes() ?: return@withContext false
Timber.tag("GOG-CloudSaves").d("Downloaded ${bytes.size} bytes for ${file.relativePath}")

// resolve against on-disk casing to avoid creating duplicate dirs
Expand All @@ -556,10 +643,12 @@ class GOGCloudSavesManager(
}

Timber.tag("GOG-CloudSaves").i("Successfully downloaded: ${file.relativePath}")
true
}

} catch (e: Exception) {
Timber.tag("GOG-CloudSaves").e(e, "Failed to download ${file.relativePath}")
false
}
}

Expand Down
28 changes: 28 additions & 0 deletions app/src/main/java/app/gamenative/service/gog/GOGManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1141,6 +1141,34 @@ class GOGManager @Inject constructor(
}
}

suspend fun checkPreLaunchConflict(
appId: String,
): GOGCloudSavesManager.SyncCheckResult? = withContext(Dispatchers.IO) {
try {
val gameId = ContainerUtils.extractGameIdFromContainerId(appId)
val game = getGameFromDbById(gameId.toString()) ?: return@withContext null
val saveLocations = getSaveDirectoryPath(context, appId, game.title)
if (saveLocations.isNullOrEmpty()) return@withContext null

val cloudSavesManager = GOGCloudSavesManager(context)
val best = saveLocations.mapNotNull { location ->
val timestamp = getCloudSaveSyncTimestamp(appId, location.name).toLongOrNull() ?: 0L
cloudSavesManager.checkSync(
clientId = location.clientId,
clientSecret = location.clientSecret,
localPath = location.location,
dirname = location.name,
lastSyncTimestamp = timestamp,
)?.takeIf { it.action == GOGCloudSavesManager.SyncAction.CONFLICT }
}.maxByOrNull { kotlin.math.abs(it.localTimestampMs - it.remoteTimestampMs) }

best
} catch (e: Exception) {
Timber.tag("GOG").e(e, "[Cloud Saves] Failed to check pre-launch conflict for appId $appId")
null
}
}

/**
* Get stored sync timestamp for a game+location
* @param appId Game app ID
Expand Down
11 changes: 11 additions & 0 deletions app/src/main/java/app/gamenative/service/gog/GOGService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,17 @@ class GOGService : Service() {
?: Result.failure(Exception("Service not available"))
}

/**
* Returns the most diverged conflicted save location, or null if there is no conflict or
* the pre-flight check could not complete.
*/
suspend fun checkPreLaunchConflict(
appId: String,
): GOGCloudSavesManager.SyncCheckResult? = withContext(Dispatchers.IO) {
val serviceInstance = getInstance() ?: return@withContext null
serviceInstance.gogManager.checkPreLaunchConflict(appId)
}

/**
* Sync GOG cloud saves for a game
* @param context Android context
Expand Down
40 changes: 40 additions & 0 deletions app/src/main/java/app/gamenative/ui/PluviaMain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1798,14 +1798,54 @@ fun preLaunchApp(
} else {
Timber.tag("GOG").i("[Cloud Saves] GOG Game detected for $appId — syncing cloud saves before launch")

if (preferredSave == SaveLocation.None) {
val conflictInfo = GOGService.checkPreLaunchConflict(appId)
if (conflictInfo != null) {
val localDate = Date(conflictInfo.localTimestampMs).toString()
val remoteDate = Date(conflictInfo.remoteTimestampMs).toString()
setMessageDialogState(
MessageDialogState(
visible = true,
type = DialogType.SYNC_CONFLICT,
title = context.getString(R.string.main_save_conflict_title),
message = context.getString(R.string.main_save_conflict_message, localDate, remoteDate),
dismissBtnText = context.getString(R.string.main_keep_local),
confirmBtnText = context.getString(R.string.main_keep_remote),
),
)
setLoadingDialogVisible(false)
return@launch
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}
}

// Sync cloud saves (download latest saves before playing)
Timber.tag("GOG").d("[Cloud Saves] Starting pre-game download sync for $appId")
val preferredAction = when (preferredSave) {
SaveLocation.Remote -> "download"
SaveLocation.Local -> "upload"
else -> "none"
}
val syncSuccess = app.gamenative.service.gog.GOGService.syncCloudSaves(
context = context,
appId = appId,
preferredAction = preferredAction,
)

if (!syncSuccess) {
if (preferredSave != SaveLocation.None) {
Timber.tag("GOG").e("[Cloud Saves] Forced sync failed for $appId with preferredSave=$preferredSave, aborting launch")
setMessageDialogState(
MessageDialogState(
visible = true,
type = DialogType.SYNC_FAIL,
title = context.getString(R.string.sync_error_title),
message = context.getString(R.string.main_sync_failed, "GOG cloud save sync"),
dismissBtnText = context.getString(R.string.ok),
),
)
setLoadingDialogVisible(false)
return@launch
}
Timber.tag("GOG").w("[Cloud Saves] Download sync failed for $appId, proceeding with launch anyway")
// Don't block launch on sync failure - log warning and continue
} else {
Expand Down
Loading