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..02de2a2f0c 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 */ @@ -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") + } + } } /** @@ -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 } // Complex sync scenario - use classifier @@ -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) + } catch (e: Exception) { + Timber.tag("GOG-CloudSaves").e(e, "checkSync failed") + null + } + } + /** * 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) { @@ -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 } @@ -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() @@ -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 } } @@ -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}") @@ -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 @@ -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 } } 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..07cb1b7ac2 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,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 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..834c9c978f 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -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 + } + } + // 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 {