Skip to content

feat: add GOG cloud save conflict resolution#1273

Draft
kiequoo wants to merge 3 commits into
utkarshdalal:masterfrom
kiequoo:feat/cloud-saves-gog-conflict
Draft

feat: add GOG cloud save conflict resolution#1273
kiequoo wants to merge 3 commits into
utkarshdalal:masterfrom
kiequoo:feat/cloud-saves-gog-conflict

Conversation

@kiequoo
Copy link
Copy Markdown
Contributor

@kiequoo kiequoo commented Apr 22, 2026

Description

This PR now contains only the remaining GOG cloud save conflict dialog work.

It follows up on the now-closed #1094 and brings GOG conflict handling in line with the existing Steam sync flow. Epic parity is intended to follow separately.

Changes in this PR:

  • add a pre-launch GOG conflict check before normal cloud sync
  • show the cloud save conflict dialog with local and remote timestamps
  • wire Keep Local to rerun launch with upload
  • wire Keep Remote to rerun launch with download
  • keep the rest of the GOG sync flow unchanged

Recording

N/A for now. Main change is the pre-launch conflict dialog flow.

Type of Change

  • Bug fix
  • Performance / stability improvement
  • Compatibility improvements
  • Other (requires prior approval)

Checklist

  • If I have access to #code-changes, I have discussed this change there and it has been green-lighted. If I do not have access, I have still provided clear context in this PR. If I skip both, I accept that this change may face delays in review, may not be reviewed at all, or may be closed.
  • This change aligns with the current project scope (core functionality, stability, or performance). If not, it has been explicitly approved beforehand.
  • I have attached a recording of the change.
  • I have read and agree to the contribution guidelines in CONTRIBUTING.md.

Summary by CodeRabbit

  • New Features

    • Pre-launch cloud-save conflict detection: shows a SYNC_CONFLICT dialog with local vs. remote timestamps and pauses launch when conflicts are found.
    • Explicit sync direction passed to sync (download, upload, none) so user choice is applied consistently.
  • Bug Fixes / Reliability

    • Forced sync now only advances sync timestamps when all file operations succeed, preventing partial-success state and reducing corrupted syncs.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 22, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bf00c904-89df-4011-91e5-5405c5995155

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a GOG cloud-save pre-flight check: a new read-only checkSync API that inspects credentials, local files, and cloud listings to determine a SyncAction and return local/remote max timestamps; integrates a pre-launch conflict check that surfaces a SYNC_CONFLICT dialog and maps user preference into sync actions.

Changes

Cohort / File(s) Summary
GOG Cloud Saves Manager
app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt
Added SyncCheckResult and checkSync(...): obtains credentials, scans local saves, lists cloud files, computes per-file SyncAction (including deletion-aware and empty/non-empty checks), extracts max local/remote update timestamps (default 0), returns SyncCheckResult? or null on missing creds/listing/errors; uploadFile/downloadFile now return Boolean and forced sync advances timestamp only if all file ops succeed.
GOG Manager
app/src/main/java/app/gamenative/service/gog/GOGManager.kt
Added checkPreLaunchConflict(appId) running on IO: resolves game and save locations, retrieves stored per-location last-sync timestamps, calls GOGCloudSavesManager.checkSync for each location, filters for SyncAction.CONFLICT, and returns the conflict with the largest timestamp delta or null.
GOG Service
app/src/main/java/app/gamenative/service/gog/GOGService.kt
Companion suspend API checkPreLaunchConflict(appId) added that delegates to GOGManager.checkPreLaunchConflict on IO and returns its SyncCheckResult?.
UI Integration (Pre-launch)
app/src/main/java/app/gamenative/ui/PluviaMain.kt
Pre-launch flow now calls pre-launch conflict check when preferredSave == SaveLocation.None; shows SYNC_CONFLICT dialog with timestamps if a conflict is returned, hides loading dialog and halts automatic sync/launch; maps preferredSave to explicit preferredAction (download/upload/none) and passes it into syncCloudSaves; adjusts failure handling to be fatal only when a preferred save was explicit.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant PluviaMain
    participant GOGService
    participant GOGManager
    participant GOGCloudSavesManager
    participant Database

    User->>PluviaMain: Launch game
    PluviaMain->>GOGService: checkPreLaunchConflict(appId)
    activate GOGService
    GOGService->>GOGManager: checkPreLaunchConflict(appId)
    activate GOGManager
    GOGManager->>Database: Resolve game & save locations
    Database-->>GOGManager: Game + locations

    loop per save location
        GOGManager->>GOGCloudSavesManager: checkSync(localPath, dirname, clientId, clientSecret, lastSyncTimestamp)
        activate GOGCloudSavesManager
        GOGCloudSavesManager->>GOGCloudSavesManager: Read local files & mtimes
        GOGCloudSavesManager->>GOGCloudSavesManager: List cloud files (API)
        GOGCloudSavesManager->>GOGCloudSavesManager: Classify files -> SyncAction
        GOGCloudSavesManager->>GOGCloudSavesManager: Compute max local/remote timestamps (ms)
        GOGCloudSavesManager-->>GOGManager: SyncCheckResult?/null
        deactivate GOGCloudSavesManager
        alt Conflict
            GOGManager->>GOGManager: Track largest timestamp delta
        end
    end

    GOGManager-->>GOGService: SyncCheckResult? (selected conflict or null)
    deactivate GOGManager
    GOGService-->>PluviaMain: SyncCheckResult? (conflict or null)
    deactivate GOGService

    alt Conflict found
        PluviaMain->>PluviaMain: Hide loading dialog
        PluviaMain->>User: Show SYNC_CONFLICT (localMs, remoteMs)
    else No conflict
        PluviaMain->>User: Proceed to sync/launch
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰
I hopped through folders, cloud and ground,
I checked each timestamp, up and down,
When conflicts glimmered, I chirped with glee,
"Choose local or cloud — the choice is free!" ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 58.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: adding GOG cloud save conflict resolution functionality.
Description check ✅ Passed The PR description covers key changes, references the related issue (#1094), and marks the change type. However, the recording checkbox is unchecked despite main change being UI-related.
Linked Issues check ✅ Passed The PR implements the conflict resolution dialog for GOG (#1094), showing local/remote timestamps and wiring Keep Local/Keep Remote actions.
Out of Scope Changes check ✅ Passed All changes relate directly to adding the pre-launch GOG conflict check and conflict resolution dialog as specified in #1094.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@kiequoo
Copy link
Copy Markdown
Contributor Author

kiequoo commented Apr 22, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 22, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (1)
app/src/main/java/app/gamenative/service/gog/GOGService.kt (1)

431-466: Consider returning a named type instead of Pair<Long, Long>.

Callers have no type-level guarantee of the (local, remote) order — a mistaken destructuring would silently swap the timestamps shown in the conflict dialog. A small data class (e.g., data class ConflictTimestamps(val localMs: Long, val remoteMs: Long)) would make this symmetric pair self-describing, especially as the result flows across GOGServicePluviaMain → the dialog.

🤖 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/GOGService.kt` around lines 431
- 466, The function checkPreLaunchConflict currently returns Pair<Long, Long>,
which is ambiguous; define a small data class (e.g., data class
ConflictTimestamps(val localMs: Long, val remoteMs: Long)) and change the return
type of checkPreLaunchConflict to ConflictTimestamps? (or List/optional of that
type) so callers get named fields instead of relying on Pair order; update where
checkPreLaunchConflict is called (e.g., usages in GOGService → PluviaMain →
dialog) to destructure or reference .localMs and .remoteMs accordingly and
adjust the construction at the return site to create
ConflictTimestamps(bestLocal, bestRemote) (return null if no conflict).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt`:
- Around line 352-382: checkSync is calling scanLocalFiles which always computes
gzipped MD5 via withMetadata() causing heavy I/O/CPU; change scanLocalFiles to
accept a flag (e.g., skipHash: Boolean) or add a lightweight variant that only
fills relativePath and updateTimestamp (no GZIP/MD5) and use that lighter scan
in checkSync; keep the full hashing behavior for callers that need md5 (preserve
withMetadata() and the original scanLocalFiles behavior) and update checkSync to
call the new lightweight scan so classifyFiles still receives relativePath and
updateTimestamp but no unnecessary md5Hash computation.

In `@app/src/main/java/app/gamenative/service/gog/GOGManager.kt`:
- Around line 1172-1187: The sync flow can leak an incomplete
CompletableDeferred if doSyncCloudSavesForApp throws a non-Exception throwable;
change the code that calls doSyncCloudSavesForApp so that endSync(appId, ...) is
invoked from a finally block regardless of what is thrown. Concretely, declare a
mutable result boolean before a try, assign it inside the try (and set false in
the catch for Exception), and call serviceInstance.gogManager.endSync(appId,
result) in finally; reference the existing methods startSync,
doSyncCloudSavesForApp, and endSync to locate the code to update.

In `@app/src/main/java/app/gamenative/service/gog/GOGService.kt`:
- Around line 487-502: The per-app sync lock may never be released if a
non-Exception Throwable escapes or if coroutine cancellation is swallowed; move
the call to serviceInstance.gogManager.endSync(appId, result) into a finally
block so it always runs, ensure result is set appropriately before finally, and
let CancellationException (and other relevant CancellationExceptions) rethrow
instead of being caught — adjust the try/catch around doSyncCloudSavesForApp to
catch only non-cancellation Exceptions and propagate cancellations, using the
existing startSync/appId and endSync(appId, result) APIs on gogManager to always
complete the CompletableDeferred in activeSyncs.

In `@app/src/main/java/app/gamenative/ui/PluviaMain.kt`:
- Around line 1793-1805: The code currently proceeds to launch even if
syncCloudSaves failed; when the user explicitly chose a direction (preferredSave
== Remote or preferredSave == Local) we must abort the launch on sync failure to
respect that choice. Modify the block after calling GOGService.syncCloudSaves so
that if syncSuccess is false AND preferredSave indicates an explicit user choice
(check preferredSave == Remote or preferredSave == Local), log an error and stop
the launch flow (return/exit from the launch path) instead of continuing; only
allow continuation for failure when preferredSave is not an explicit choice
(e.g., Automatic/None). Keep existing log messages for success and non-fatal
failures.

---

Nitpick comments:
In `@app/src/main/java/app/gamenative/service/gog/GOGService.kt`:
- Around line 431-466: The function checkPreLaunchConflict currently returns
Pair<Long, Long>, which is ambiguous; define a small data class (e.g., data
class ConflictTimestamps(val localMs: Long, val remoteMs: Long)) and change the
return type of checkPreLaunchConflict to ConflictTimestamps? (or List/optional
of that type) so callers get named fields instead of relying on Pair order;
update where checkPreLaunchConflict is called (e.g., usages in GOGService →
PluviaMain → dialog) to destructure or reference .localMs and .remoteMs
accordingly and adjust the construction at the return site to create
ConflictTimestamps(bestLocal, bestRemote) (return null if no conflict).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f2cbc62b-0c5a-47a9-9164-66049d7b841c

📥 Commits

Reviewing files that changed from the base of the PR and between 93793fa and 6749829.

📒 Files selected for processing (7)
  • app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt
  • app/src/main/java/app/gamenative/service/gog/GOGManager.kt
  • app/src/main/java/app/gamenative/service/gog/GOGService.kt
  • app/src/main/java/app/gamenative/ui/PluviaMain.kt
  • app/src/main/java/app/gamenative/ui/model/MainViewModel.kt
  • app/src/main/java/app/gamenative/ui/screen/library/appscreen/GOGAppScreen.kt
  • app/src/test/java/app/gamenative/ui/screen/library/appscreen/GOGAppScreenTest.kt

Comment thread app/src/main/java/app/gamenative/service/gog/GOGManager.kt Outdated
Comment thread app/src/main/java/app/gamenative/service/gog/GOGService.kt Outdated
Comment thread app/src/main/java/app/gamenative/ui/PluviaMain.kt Outdated
@kiequoo kiequoo marked this pull request as ready for review April 22, 2026 15:42
@kiequoo kiequoo requested a review from utkarshdalal as a code owner April 22, 2026 15:42
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt (1)

217-238: ⚠️ Potential issue | 🟠 Major

Propagate forced transfer failures before returning success.

For SaveLocation.Remote/Local, downloadFile and uploadFile only log failures and return Unit, so this block can return currentTimestamp() even when the chosen conflict resolution did not transfer files. That makes GOGService.syncCloudSaves report success and preLaunchApp can launch despite a failed “Keep Local/Keep Remote” action.

Consider making the transfer helpers return Boolean and returning 0L if any forced transfer fails.

🛡️ Sketch
 if (preferredSave == SaveLocation.Remote) {
     if (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.all { file ->
+            downloadFile(credentials.userId, clientId, dirname, file, syncDir, credentials.accessToken)
+        }
+        if (!allDownloaded) {
+            return@withContext 0L
         }
         return@withContext currentTimestamp()
     } else {
         // All cloud files are deletion markers; user asked for remote but there's nothing to download.
         // Fall through to auto-sync so we don't silently ignore the request.
         Timber.tag("GOG-CloudSaves").w("preferredSave=Remote but no downloadable cloud files; falling through to auto-sync")
     }
 }
 
 if (preferredSave == SaveLocation.Local && 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.all { file ->
+        uploadFile(credentials.userId, clientId, dirname, file, credentials.accessToken)
+    }
+    if (!allUploaded) {
+        return@withContext 0L
     }
     return@withContext currentTimestamp()
 }

This also requires changing uploadFile(...) and downloadFile(...) to return Boolean.

🤖 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 217 - 238, The forced-transfer paths in GOGCloudSavesManager currently
call downloadFile(...) and uploadFile(...) which only log failures, so the
method can still return currentTimestamp() even if transfers failed; change
downloadFile(...) and uploadFile(...) to return Boolean (true on success, false
on failure), update the loops in the preferredSave==SaveLocation.Remote and
preferredSave==SaveLocation.Local branches to collect the results and if any
transfer returns false then return 0L instead of currentTimestamp(), otherwise
return currentTimestamp() as before; keep Timber logging in the helpers but use
the Boolean result to propagate failure to the caller.
♻️ Duplicate comments (1)
app/src/main/java/app/gamenative/service/gog/GOGService.kt (1)

481-493: ⚠️ Potential issue | 🟠 Major

Keep endSync in a finally so waiters always complete.

The cancellation path now releases the lock, but any non-Exception Throwable from doSyncCloudSavesForApp still skips Line 491, leaving the CompletableDeferred in activeSyncs incomplete for future callers. Put the release in finally and rethrow cancellation from the catch.

🛡️ Proposed fix
-            val result = try {
-                doSyncCloudSavesForApp(context, serviceInstance, appId, preferredSave)
-            } catch (e: CancellationException) {
-                serviceInstance.gogManager.endSync(appId, false)
-                Timber.tag("GOG").d("[Cloud Saves] Sync completed and lock released for $appId")
-                throw e
-            } catch (e: Exception) {
-                Timber.tag("GOG").e(e, "[Cloud Saves] Failed to sync cloud saves for App ID: $appId")
-                false
-            }
-            serviceInstance.gogManager.endSync(appId, result)
-            Timber.tag("GOG").d("[Cloud Saves] Sync completed and lock released for $appId")
-            result
+            var result = false
+            try {
+                result = doSyncCloudSavesForApp(context, serviceInstance, appId, preferredSave)
+                result
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Exception) {
+                Timber.tag("GOG").e(e, "[Cloud Saves] Failed to sync cloud saves for App ID: $appId")
+                false
+            } finally {
+                serviceInstance.gogManager.endSync(appId, result)
+                Timber.tag("GOG").d("[Cloud Saves] Sync completed and lock released for $appId")
+            }
🤖 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/GOGService.kt` around lines 481
- 493, The endSync call must be moved into a finally block so any Throwable
(including non-Exception errors) releases the lock; change the current try/catch
to set a local result boolean (default false), run doSyncCloudSavesForApp inside
try to assign result, catch CancellationException only to rethrow (after any
needed log), catch Exception to log and keep result=false, and then in finally
call serviceInstance.gogManager.endSync(appId, result) and the Timber release
log; keep doSyncCloudSavesForApp, gogManager.endSync, and the Timber tag
references so the fix is applied to the same logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/java/app/gamenative/ui/PluviaMain.kt`:
- Around line 1800-1805: When syncSuccess is false and the user chose an
explicit preferredSave (SaveLocation.Local or SaveLocation.Remote) the code
currently logs and aborts (Timber.tag("GOG").e(...),
setLoadingDialogVisible(false), return@launch) but does not surface an error to
the user; before returning from the coroutine in that branch, call the same
user-visible sync failure dialog routine (the function that shows the sync
failure UI) or dispatch the existing dialog/path used for non-explicit failures
so the user sees an explanation, then hide loading
(setLoadingDialogVisible(false)) and return@launch; update the branch that
checks preferredSave to invoke the dialog/showError method prior to the return.

---

Outside diff comments:
In `@app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt`:
- Around line 217-238: The forced-transfer paths in GOGCloudSavesManager
currently call downloadFile(...) and uploadFile(...) which only log failures, so
the method can still return currentTimestamp() even if transfers failed; change
downloadFile(...) and uploadFile(...) to return Boolean (true on success, false
on failure), update the loops in the preferredSave==SaveLocation.Remote and
preferredSave==SaveLocation.Local branches to collect the results and if any
transfer returns false then return 0L instead of currentTimestamp(), otherwise
return currentTimestamp() as before; keep Timber logging in the helpers but use
the Boolean result to propagate failure to the caller.

---

Duplicate comments:
In `@app/src/main/java/app/gamenative/service/gog/GOGService.kt`:
- Around line 481-493: The endSync call must be moved into a finally block so
any Throwable (including non-Exception errors) releases the lock; change the
current try/catch to set a local result boolean (default false), run
doSyncCloudSavesForApp inside try to assign result, catch CancellationException
only to rethrow (after any needed log), catch Exception to log and keep
result=false, and then in finally call serviceInstance.gogManager.endSync(appId,
result) and the Timber release log; keep doSyncCloudSavesForApp,
gogManager.endSync, and the Timber tag references so the fix is applied to the
same logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 43d27570-11ac-431a-90d4-b61f90cb31bd

📥 Commits

Reviewing files that changed from the base of the PR and between 6749829 and 832961c.

📒 Files selected for processing (3)
  • app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt
  • app/src/main/java/app/gamenative/service/gog/GOGService.kt
  • app/src/main/java/app/gamenative/ui/PluviaMain.kt

Comment thread app/src/main/java/app/gamenative/ui/PluviaMain.kt
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

4 issues found across 7 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt">

<violation number="1" location="app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt:97">
P2: Metadata error path drops local timestamps, which can hide updated local files from sync classification.</violation>

<violation number="2" location="app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt:388">
P2: `scanLocalFilesLite` duplicates recursive scan/path-normalization logic from `scanLocalFiles`, creating drift risk between preflight and sync behavior.</violation>
</file>

<file name="app/src/main/java/app/gamenative/service/gog/GOGService.kt">

<violation number="1" location="app/src/main/java/app/gamenative/service/gog/GOGService.kt:478">
P2: Concurrent sync de-duplication by `appId` can drop a later caller’s forced `preferredSave` direction, causing user-selected conflict resolution (upload/download) to be ignored.</violation>
</file>

<file name="app/src/main/java/app/gamenative/ui/PluviaMain.kt">

<violation number="1" location="app/src/main/java/app/gamenative/ui/PluviaMain.kt:1789">
P2: Forced GOG conflict-resolution sync failure aborts launch silently, leaving users without a visible error after selecting local/remote save.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt Outdated
Comment thread app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt Outdated
Comment thread app/src/main/java/app/gamenative/service/gog/GOGService.kt Outdated
Comment thread app/src/main/java/app/gamenative/ui/PluviaMain.kt
@kiequoo kiequoo marked this pull request as draft April 22, 2026 15:55
@kiequoo kiequoo force-pushed the feat/cloud-saves-gog-conflict branch 3 times, most recently from 1331f75 to 98c9809 Compare April 23, 2026 09:36
@kiequoo kiequoo changed the title feat: GOG cloud save conflict resolution feat: add GOG cloud save conflict resolution Apr 23, 2026
@kiequoo kiequoo force-pushed the feat/cloud-saves-gog-conflict branch 5 times, most recently from 995cd2f to 13cc95a Compare April 23, 2026 18:23
@kiequoo
Copy link
Copy Markdown
Contributor Author

kiequoo commented Apr 23, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 23, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
app/src/main/java/app/gamenative/service/gog/GOGManager.kt (1)

1152-1162: Optional: parallelize per-location checkSync calls.

Each save location triggers an independent HTTP round-trip inside checkSync (cloud file listing). Running them sequentially directly on the pre-launch path adds latency proportional to location count. For games with multiple save locations, awaiting these in parallel would cut pre-launch wait time without changing semantics, since the locations are independent and the reducer is maxByOrNull.

♻️ Sketch
-        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) }
+        val best = coroutineScope {
+            saveLocations.map { location ->
+                async {
+                    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 }
+                }
+            }.awaitAll()
+        }.filterNotNull().maxByOrNull { kotlin.math.abs(it.localTimestampMs - it.remoteTimestampMs) }
🤖 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/GOGManager.kt` around lines 1152
- 1162, The sequential mapNotNull over saveLocations calls
GOGCloudSavesManager.checkSync per location and causes serial HTTP latency; make
these independent checks run concurrently by launching them as coroutines (e.g.,
using coroutineScope/Dispatchers.IO and async for each location), await all
results (awaitAll), then perform the same filtering (.takeIf { it.action ==
GOGCloudSavesManager.SyncAction.CONFLICT }) and compute the max with
.maxByOrNull on the completed results to produce best; reference
GOGCloudSavesManager, checkSync, saveLocations, and best when applying this
change.
app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt (1)

370-373: Consider letting CancellationException propagate.

The broad catch (e: Exception) here swallows kotlinx.coroutines.CancellationException (it extends Exception), turning coroutine cancellation into a benign null result. On the pre-launch path that invokes checkSync, if the caller scope is cancelled mid-request the cancellation is consumed and never re-thrown. Rethrowing CancellationException before the generic handler keeps cancellation cooperative:

♻️ Sketch
-        } catch (e: Exception) {
+        } catch (e: kotlinx.coroutines.CancellationException) {
+            throw e
+        } catch (e: Exception) {
             Timber.tag("GOG-CloudSaves").e(e, "checkSync failed")
             null
         }
🤖 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 370 - 373, The catch block in GOGCloudSavesManager.checkSync is swallowing
kotlinx.coroutines.CancellationException by catching Exception; update the error
handling to rethrow CancellationException (e.g., detect if e is
CancellationException and throw it) before handling/logging the generic
Exception so coroutine cancellation can propagate correctly while still logging
other failures from checkSync.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt`:
- Around line 370-373: The catch block in GOGCloudSavesManager.checkSync is
swallowing kotlinx.coroutines.CancellationException by catching Exception;
update the error handling to rethrow CancellationException (e.g., detect if e is
CancellationException and throw it) before handling/logging the generic
Exception so coroutine cancellation can propagate correctly while still logging
other failures from checkSync.

In `@app/src/main/java/app/gamenative/service/gog/GOGManager.kt`:
- Around line 1152-1162: The sequential mapNotNull over saveLocations calls
GOGCloudSavesManager.checkSync per location and causes serial HTTP latency; make
these independent checks run concurrently by launching them as coroutines (e.g.,
using coroutineScope/Dispatchers.IO and async for each location), await all
results (awaitAll), then perform the same filtering (.takeIf { it.action ==
GOGCloudSavesManager.SyncAction.CONFLICT }) and compute the max with
.maxByOrNull on the completed results to produce best; reference
GOGCloudSavesManager, checkSync, saveLocations, and best when applying this
change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f594ccb4-aacb-43bf-9904-04dc796b7451

📥 Commits

Reviewing files that changed from the base of the PR and between 832961c and 13cc95a.

📒 Files selected for processing (4)
  • app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt
  • app/src/main/java/app/gamenative/service/gog/GOGManager.kt
  • app/src/main/java/app/gamenative/service/gog/GOGService.kt
  • app/src/main/java/app/gamenative/ui/PluviaMain.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/src/main/java/app/gamenative/ui/PluviaMain.kt

@kiequoo kiequoo marked this pull request as ready for review April 23, 2026 18:37
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

2 issues found across 4 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="app/src/main/java/app/gamenative/service/gog/GOGManager.kt">

<violation number="1" location="app/src/main/java/app/gamenative/service/gog/GOGManager.kt:1155">
P2: `checkPreLaunchConflict` lacks exception handling in a pre-launch path, so sync-check errors can escape and interrupt game launch instead of returning `null` gracefully.</violation>

<violation number="2" location="app/src/main/java/app/gamenative/service/gog/GOGManager.kt:1162">
P1: Pre-launch conflict check drops additional conflicting save locations by selecting only one “best” conflict.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread app/src/main/java/app/gamenative/service/gog/GOGManager.kt Outdated
Comment thread app/src/main/java/app/gamenative/service/gog/GOGManager.kt Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt`:
- Around line 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.
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9014e775-3cf8-4c8a-8e2e-6e816138ca89

📥 Commits

Reviewing files that changed from the base of the PR and between 13cc95a and bf0f431.

📒 Files selected for processing (2)
  • app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt
  • app/src/main/java/app/gamenative/ui/PluviaMain.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/src/main/java/app/gamenative/ui/PluviaMain.kt

Comment on lines 221 to 235
if (preferredAction == "download" && downloadableCloud.isNotEmpty()) {
Timber.tag("GOG-CloudSaves").i("Forcing download of ${downloadableCloud.size} file(s) (user requested)")
downloadableCloud.forEach { file ->
val allDownloaded = downloadableCloud.all { file ->
downloadFile(credentials.userId, clientId, dirname, file, syncDir, credentials.accessToken)
}
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 ->
val allUploaded = localFiles.all { file ->
uploadFile(credentials.userId, clientId, dirname, file, credentials.accessToken)
}
return@withContext currentTimestamp()
return@withContext if (allUploaded) currentTimestamp() else 0L
}
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.

Comment on lines +367 to +369
val localMax = localFiles.maxOfOrNull { it.updateTimestamp ?: 0L } ?: 0L
val remoteMax = cloudFiles.maxOfOrNull { it.updateTimestamp ?: 0L } ?: 0L
SyncCheckResult(action, localMax * 1000, remoteMax * 1000)
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.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

2 issues found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt">

<violation number="1" location="app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt:223">
P1: `Iterable.all` short-circuits on the first `false` return, so if one file fails to download/upload, the remaining files are silently skipped. For an explicit user-initiated sync direction (Keep Local / Keep Remote), this leaves local and cloud state partially transferred, which can cause spurious conflicts on retry. Use `fold` (or similar) to attempt every file before reporting overall success.</violation>

<violation number="2" location="app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt:231">
P1: Same short-circuit issue as the download path: `localFiles.all` will skip remaining uploads after the first failure. Use `fold` to attempt every file.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt Outdated
Comment thread app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt Outdated
@kiequoo kiequoo marked this pull request as draft April 23, 2026 19:15
@kiequoo kiequoo force-pushed the feat/cloud-saves-gog-conflict branch from bf0f431 to 9f6a335 Compare April 23, 2026 19:37
@utkarshdalal
Copy link
Copy Markdown
Owner

Hey @kiequoo , still working on this?

@kiequoo
Copy link
Copy Markdown
Contributor Author

kiequoo commented May 12, 2026

Hey @kiequoo , still working on this?

Yes, just been busy recently. I'll try and pick it up again this week!

@utkarshdalal
Copy link
Copy Markdown
Owner

cheers

kiequoo added 2 commits May 14, 2026 19:48
…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
@kiequoo kiequoo force-pushed the feat/cloud-saves-gog-conflict branch from 9f6a335 to 3217efa Compare May 14, 2026 17:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants