From d44ae360b89bb4d904c46bc30d60d93dce45f98a Mon Sep 17 00:00:00 2001 From: Dan Brooke Date: Thu, 23 Apr 2026 20:07:45 +0200 Subject: [PATCH 1/3] feat: add GOG cloud save conflict dialog --- .../service/gog/GOGCloudSavesManager.kt | 42 +++++++++++++++++++ .../app/gamenative/service/gog/GOGManager.kt | 23 ++++++++++ .../app/gamenative/service/gog/GOGService.kt | 11 +++++ .../main/java/app/gamenative/ui/PluviaMain.kt | 26 ++++++++++++ 4 files changed, 102 insertions(+) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt index 7e9c4b0cc4..261b018ecb 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt @@ -45,6 +45,12 @@ class GOGCloudSavesManager( NONE } + data class SyncCheckResult( + val action: SyncAction, + val localTimestampMs: Long, + val remoteTimestampMs: Long, + ) + /** * Represents a local save file */ @@ -331,6 +337,42 @@ 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) 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.maxOfOrNull { it.updateTimestamp ?: 0L } ?: 0L + SyncCheckResult(action, localMax * 1000, remoteMax * 1000) + } catch (e: Exception) { + Timber.tag("GOG-CloudSaves").e(e, "checkSync failed") + null + } + } + /** * Scan local directory for save files */ diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 5d40809a12..4b43bd0e31 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -1141,6 +1141,29 @@ class GOGManager @Inject constructor( } } + suspend fun checkPreLaunchConflict( + appId: String, + ): GOGCloudSavesManager.SyncCheckResult? = withContext(Dispatchers.IO) { + 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 + } + /** * Get stored sync timestamp for a game+location * @param appId Game app ID diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index b46bc8d2ab..510ec53431 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -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 diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index b68ea5415c..989cae9248 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -1798,11 +1798,37 @@ 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 + } + } + // 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) { From 3217efa5e9c97b13dc18656eb216350aeecaddc3 Mon Sep 17 00:00:00 2001 From: Dan Brooke Date: Thu, 23 Apr 2026 21:07:14 +0200 Subject: [PATCH 2/3] fix: propagate forced transfer failures and abort launch on conflict sync failure As pointed out by CodeRabbit and Cubic in PR review: - uploadFile/downloadFile now return Boolean so forced-transfer paths (preferredAction download/upload) can detect and surface failures - forced download/upload now attempts every file before reporting overall failure, instead of short-circuiting on the first transfer error - preLaunchApp now aborts launch with a SYNC_FAIL dialog when an explicit conflict resolution sync (Keep Local/Keep Remote) fails, instead of silently proceeding - pre-launch conflict timestamps now ignore deleted cloud tombstones so the dialog reflects real save payload timestamps --- .../service/gog/GOGCloudSavesManager.kt | 44 ++++++++++++++----- .../main/java/app/gamenative/ui/PluviaMain.kt | 14 ++++++ 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt index 261b018ecb..4bc069f5c9 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt @@ -220,18 +220,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 } // Complex sync scenario - use classifier @@ -365,7 +380,9 @@ class GOGCloudSavesManager( } val localMax = localFiles.maxOfOrNull { it.updateTimestamp ?: 0L } ?: 0L - val remoteMax = cloudFiles.maxOfOrNull { it.updateTimestamp ?: 0L } ?: 0L + val remoteMax = cloudFiles + .filter { !it.isDeleted } + .maxOfOrNull { it.updateTimestamp ?: 0L } ?: 0L SyncCheckResult(action, localMax * 1000, remoteMax * 1000) } catch (e: Exception) { Timber.tag("GOG-CloudSaves").e(e, "checkSync failed") @@ -509,7 +526,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() @@ -537,15 +554,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 } } @@ -559,7 +579,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}") @@ -578,10 +598,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 @@ -598,10 +618,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 } } diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index 989cae9248..834c9c978f 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -1832,6 +1832,20 @@ fun preLaunchApp( ) 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 { From 5620c36060d792aef59aa3163fd6ec901bb25016 Mon Sep 17 00:00:00 2001 From: Dan Brooke Date: Fri, 15 May 2026 10:29:42 +0200 Subject: [PATCH 3/3] Optimize GOG pre-launch cloud save check --- .../service/gog/GOGCloudSavesManager.kt | 33 +++++++++++++-- .../app/gamenative/service/gog/GOGManager.kt | 41 +++++++++++-------- 2 files changed, 52 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt index 4bc069f5c9..02de2a2f0c 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt @@ -100,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") + } + } } /** @@ -368,7 +385,7 @@ class GOGCloudSavesManager( .getOrNull() ?: return@withContext null val syncDir = File(localPath) - val localFiles = if (syncDir.exists()) scanLocalFiles(syncDir) else emptyList() + val localFiles = if (syncDir.exists()) scanLocalFiles(syncDir, calculateHashes = false) else emptyList() val cloudFiles = getCloudFiles(credentials.userId, clientId, dirname, credentials.accessToken) ?: return@withContext null @@ -393,7 +410,10 @@ class GOGCloudSavesManager( /** * Scan local directory for save files */ - private suspend fun scanLocalFiles(directory: File): List = withContext(Dispatchers.IO) { + private suspend fun scanLocalFiles( + directory: File, + calculateHashes: Boolean = true, + ): List = withContext(Dispatchers.IO) { val files = mutableListOf() fun scanRecursive(dir: File, basePath: String) { @@ -411,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 } diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 4b43bd0e31..07cb1b7ac2 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -1144,24 +1144,29 @@ class GOGManager @Inject constructor( suspend fun checkPreLaunchConflict( appId: String, ): GOGCloudSavesManager.SyncCheckResult? = withContext(Dispatchers.IO) { - 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 + 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 + } } /**