Skip to content

fix: GOG cloud save fetch failure handling#1201

Open
kiequoo wants to merge 3 commits intoutkarshdalal:masterfrom
kiequoo:feat/cloud-saves-gog-fixes
Open

fix: GOG cloud save fetch failure handling#1201
kiequoo wants to merge 3 commits intoutkarshdalal:masterfrom
kiequoo:feat/cloud-saves-gog-fixes

Conversation

@kiequoo
Copy link
Copy Markdown
Contributor

@kiequoo kiequoo commented Apr 13, 2026

Summary

This is a split-out PR from #1094 focused on the GOG cloud save fetch and timestamp fixes.

This PR fixes two bugs in GOG cloud save sync and one related support issue in save-location resolution.

Before this change, getCloudFiles() treated request or parse failures the same as a valid empty cloud result. That could make sync logic believe the cloud was empty and incorrectly choose an upload path. It also parsed GOG last_modified timestamps with Instant.parse(...), which does not handle the offset-based format GOG returns, so remote timestamps could be dropped during sync decisions.

It also removes the fake default GOG cloud-save path fallback when the API returns no save locations. That fallback is not useful, because in the same case we also have no clientSecret, so cloud auth cannot succeed and the path can never be used for a real sync.

Changes

  • Return null from GOG cloud file listing on HTTP or parse failure instead of emptyList()
  • Abort sync when cloud file listing fails instead of treating the failure as cloud is empty
  • Parse GOG cloud timestamps with OffsetDateTime.parse(...).toInstant()
  • Stop falling back to a fake default cloud-save location when the API reports no save locations
  • Add regression tests covering offset timestamp parsing and failure-vs-empty cloud responses

Summary by CodeRabbit

  • Bug Fixes

    • Improved error handling for cloud save operations—failures now properly abort instead of continuing silently.
    • Fixed cloud save support detection—when the service reports no available locations, cloud saves are now correctly reported as unsupported instead of using a fallback path.
    • Enhanced timestamp parsing for cloud save metadata to handle edge cases more robustly.
  • Tests

    • Added comprehensive test coverage for cloud save file parsing and timestamp handling.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 13, 2026

📝 Walkthrough

Walkthrough

GOG cloud saves handling is refactored to explicitly propagate failures as null values rather than silently using defaults. The getCloudFiles return type changes to nullable, JSON parsing logic is extracted into dedicated helpers with improved error handling, and timestamp parsing is updated to use OffsetDateTime. GOGManager no longer fabricates default paths when the API returns no locations. Tests verify the new parsing behavior.

Changes

Cohort / File(s) Summary
Cloud Saves Error Handling
app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt, app/src/main/java/app/gamenative/service/gog/GOGManager.kt
Modified getCloudFiles() to return nullable List<CloudFile>? instead of empty lists on failure. Refactored timestamp parsing to use OffsetDateTime with proper exception handling. Extracted JSON response parsing into parseCloudFilesResponse() and parseCloudTimestamp() helpers. Updated syncSaves() to abort (return 0L) when cloud files result is null. Removed default cloud-save path fabrication in GOGManager.getSaveDirectoryPath() when API returns no locations; now returns null instead.
Cloud Saves Parsing Tests
app/src/test/java/app/gamenative/service/gog/GOGCloudSavesManagerTest.kt
Added comprehensive test class with three test methods: parseCloudTimestamp_accepts_gog_offset_format() validates ISO-8601 timestamp with microseconds and offset parsing; parseCloudFilesResponse_returns_null_for_invalid_json_instead_of_empty_list() confirms null on parse failure; parseCloudFilesResponse_parses_matching_files_and_preserves_offset_timestamp() verifies file filtering by __default prefix and timestamp preservation.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 No more defaults in the dark,
Failures now leave a clear mark,
Null says "nope" instead of pretend,
Error paths honestly bend,
Tests now prove the rabbit's way!

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description includes a comprehensive Summary and detailed Changes section explaining the bugs, fixes, and rationale. However, the description is incomplete: it does not include a Recording section or checked Checklist items as required by the template. Add a Recording section (with a link or note if unavailable) and complete the Checklist by checking items 1-3 or explaining why they do not apply.
Docstring Coverage ⚠️ Warning Docstring coverage is 44.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title directly summarizes the main fix: improving failure handling for GOG cloud save fetching, which aligns with the core changes across all modified files.

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

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

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 kiequoo changed the title Fix GOG cloud save fetch failure handling fix: GOG cloud save fetch failure handling Apr 13, 2026
@kiequoo kiequoo marked this pull request as ready for review April 13, 2026 15:08
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 (1)
app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt (1)

415-451: Consider wrapping the loop to consistently return null on malformed items.

The function explicitly returns null when JSONArray(responseBody) fails (line 420), but if an array element isn't a JSON object, getJSONObject(i) at line 427 throws an uncaught exception. While this is caught by the caller in getCloudFiles, it's inconsistent with the explicit null handling and could cause issues if this internal function is called from other contexts (e.g., tests).

♻️ Suggested refactor to handle malformed array elements
         val files = mutableListOf<CloudFile>()
         for (i in 0 until items.length()) {
-            val fileObj = items.getJSONObject(i)
+            val fileObj = try {
+                items.getJSONObject(i)
+            } catch (e: Exception) {
+                Timber.tag("GOG").w("[Cloud Saves] Skipping malformed item at index $i")
+                continue
+            }
             val name = fileObj.optString("name", "")
🤖 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 415 - 451, The loop in parseCloudFilesResponse uses items.getJSONObject(i)
which will throw for non-object elements, making error handling inconsistent
with the earlier JSON parse that returns null; update parseCloudFilesResponse to
guard each element access by wrapping items.getJSONObject(i) (and subsequent
per-item parsing) in a try/catch that logs the error (Timber.tag("GOG").e(...))
and returns null on any malformed item so the function consistently returns null
for malformed responses (keep references to parseCloudTimestamp, CloudFile
construction, dirname check and existing log messages intact), and note that
callers like getCloudFiles will then continue to receive null for invalid
arrays.
🤖 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 415-451: The loop in parseCloudFilesResponse uses
items.getJSONObject(i) which will throw for non-object elements, making error
handling inconsistent with the earlier JSON parse that returns null; update
parseCloudFilesResponse to guard each element access by wrapping
items.getJSONObject(i) (and subsequent per-item parsing) in a try/catch that
logs the error (Timber.tag("GOG").e(...)) and returns null on any malformed item
so the function consistently returns null for malformed responses (keep
references to parseCloudTimestamp, CloudFile construction, dirname check and
existing log messages intact), and note that callers like getCloudFiles will
then continue to receive null for invalid arrays.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1f37d95f-ccc0-4d57-a6e5-258d4cd8a7f3

📥 Commits

Reviewing files that changed from the base of the PR and between ebbb479 and 29d010f.

📒 Files selected for processing (3)
  • app/src/main/java/app/gamenative/service/gog/GOGCloudSavesManager.kt
  • app/src/main/java/app/gamenative/service/gog/GOGManager.kt
  • app/src/test/java/app/gamenative/service/gog/GOGCloudSavesManagerTest.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.

No issues found across 3 files

@phobos665
Copy link
Copy Markdown
Contributor

Please make sure to use the PR template :)

Comment on lines +411 to +460
null
}
}

internal fun parseCloudFilesResponse(responseBody: String, dirname: String): List<CloudFile>? {
val items = try {
JSONArray(responseBody)
} catch (e: Exception) {
Timber.tag("GOG").e(e, "[Cloud Saves] Failed to parse JSON array response")
return null
}

Timber.tag("GOG").d("[Cloud Saves] Found ${items.length()} total items in cloud storage")

val files = mutableListOf<CloudFile>()
for (i in 0 until items.length()) {
val fileObj = items.getJSONObject(i)
val name = fileObj.optString("name", "")
val hash = fileObj.optString("hash", "")
val lastModified = fileObj.optString("last_modified")

Timber.tag("GOG").d("[Cloud Saves] Examining item $i: name='$name', dirname='$dirname'")

if (name.isNotEmpty() && hash.isNotEmpty() && name.startsWith("$dirname/")) {
val relativePath = name.removePrefix("$dirname/")
files.add(
CloudFile(
relativePath = relativePath,
md5Hash = hash,
updateTime = lastModified,
updateTimestamp = parseCloudTimestamp(lastModified),
),
)
Timber.tag("GOG").d("[Cloud Saves] ✓ Matched: relativePath='$relativePath'")
} else {
Timber.tag("GOG").d("[Cloud Saves] ✗ Skipped (doesn't match dirname or missing data)")
}
}

return files
}

internal fun parseCloudTimestamp(lastModified: String): Long? =
try {
// GOG returns timestamps like "2026-04-02T20:34:00.123456+00:00".
OffsetDateTime.parse(lastModified).toInstant().epochSecond
} catch (_: DateTimeParseException) {
null
}

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.

Nice, I like these being split out into internal files for easy unit testing

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