-
Notifications
You must be signed in to change notification settings - Fork 981
Description
Describe the bug
After a device commits a saved game while offline and later reconnects, subsequent save attempts permanently fail to upload to server even when online. The device becomes stuck in this state indefinitely with NO recovery mechanism except triggering a conflict from another device.
Verified via Google Takeout: Server data confirmed to be stale/outdated, proving uploads are failing.
Root Cause (from reverse engineering): The SnapshotMetadata.hasChangePending() method in the underlying Google Play Services library returns stale data due to an immutable final boolean field in cached SnapshotMetadataEntity objects that never updates after successful upload.
To Reproduce
Steps to reproduce the behavior:
- Initialize GPGS with saved games on Device A
- Disable internet connection on Device A (go offline)
- Call
PlayGamesPlatform.Instance.SavedGame.CommitUpdate()to save game while offline - Re-enable internet connection on Device A (go back online)
- Call
PlayGamesPlatform.Instance.SavedGame.CommitUpdate()to save game while online - Verification Method 1: Check saved games on another device or Play Games web → New data is NOT on server
- Verification Method 2: Export account data via Google Takeout → Select "Play Games Services" → Confirms server has STALE/OUTDATED data
- Try restarting app → Issue PERSISTS (not a memory cache issue)
- Try using
DataSource.ReadNetworkOnly→ STILL FAILS (cache persists in Play Services system storage) - ONLY FIX: Commit from Device B with same account → Triggers conflict → After resolving conflict, Device A can upload again
Expected behavior
After reconnecting to the internet, subsequent save operations should successfully upload to the server, regardless of previous offline commits.
If using DataSource.ReadCacheOrNetwork with pending changes, SDK should:
- Check if cached metadata has
hasChangePending() = true - If true AND device is online, validate with server first
- Update cache with fresh server state
- Then proceed with operation
Observed behavior
Save operations silently fail to upload to the server. The device is permanently stuck with no self-recovery mechanism:
- ❌ App restart doesn't help
- ❌ Network reconnection doesn't help
- ❌ Waiting doesn't help
- ❌ Even
DataSource.ReadNetworkOnlydoesn't help - ❌ Google Takeout confirms: Server data is STALE/OUTDATED
- ✅ ONLY conflict resolution from another device fixes it
Impact:
- Single device: Local saves work, but data never syncs to cloud
- Multi-device scenario: Switching to Device B fetches outdated data from server → All progress on Device A after bug = PERMANENTLY LOST
- Device loss/damage: All local-only progress lost forever (never uploaded to server)
Real-World Example:
Device A (stuck after offline play):
Day 1-3: Play online, reach Level 20 (local ✅, server still Level 5 ❌)
Google Takeout: Shows Level 5 (stale data)
Switch to Device B:
Fetches from server → Gets Level 5 (outdated!)
Progress Level 5→20 from Device A = LOST ❌
Bug Report
I can provide Android bug report if requested. However, the issue is reproducible via Google Takeout verification (server data inspection) which definitively proves upload failure.
Screenshots
N/A - Bug is related to server upload failure, verifiable via Google Takeout data export.
Versions
- Unity version: 2022.3.62f2
- Google Play Games Plugin for Unity version: v2.0.0
- Dependencies from
mainTemplate.gradle:implementation 'com.google.games:gpgs-plugin-support:2.0.0' implementation 'com.google.android.gms:play-services-base:18.9.0'
Additional context
Severity: CRITICAL
This bug affects ALL games using offline save functionality. Single offline session permanently breaks cloud sync for that device.
Technical Analysis (Reverse Engineering)
Methodology Note: This analysis was performed by decompiling the publicly available
play-services-games:24.0.0library using jadx for bug investigation purposes only.
Root Cause: Immutable Field in SnapshotMetadataEntity
The underlying Google Play Services library (com.google.android.gms:play-services-games) has a critical design flaw in SnapshotMetadataEntity:
// SnapshotMetadataEntity.java (decompiled from play-services-games:24.0.0)
// Line 65-66: Field declaration
@SafeParcelable.Field(id = 13, getter = "hasChangePending")
private final boolean zzl; // ← IMMUTABLE! Cannot be changed after construction
// Line 88: Constructor sets value once
this.zzl = z; // Set once, never updated
// Line 156-158: Getter method
@Override
public boolean hasChangePending() {
return this.zzl; // Always returns the initial value
}There is NO setter method for this field after object construction.
Why It Causes the Bug
- Offline commit → SDK creates
SnapshotMetadataEntitywithzzl = true - Entity is persisted in Play Services database (
/data/data/com.google.android.gms/) - Device reconnects → Cached entity still has
zzl = true(immutable!) - App calls
OpenWithAutomaticConflictResolutionwithDataSource.ReadCacheOrNetwork:- SDK returns cached metadata without server validation
- Cached metadata has
hasChangePending() = true(stale!)
- SDK sees
pending=true→ assumes "upload already in progress" → blocks new commit - Infinite loop - Cache never invalidates, even across app restarts
- Cache only refreshes when conflict resolution forces fresh server fetch
Correct Implementation Exists (SnapshotMetadataRef)
The library includes another implementation that does NOT have this bug:
// SnapshotMetadataRef.java (line 90-92)
@Override
public final boolean hasChangePending() {
return getInteger("pending_change_count") > 0; // Dynamically reads from database
}This implementation correctly reflects real-time state by querying the database, which updates after successful sync.
The freeze() Mechanism
// SnapshotMetadataRef.java - Line 129-130
public final Object freeze() {
return new SnapshotMetadataEntity(this); // Creates immutable snapshot
}
// SnapshotMetadataEntity.java - Line 257 (Copy Constructor)
this.zzl = snapshotMetadata.hasChangePending(); // Snapshots current valueWhen .freeze() is called, it creates a SnapshotMetadataEntity that snapshots the current state. Once cached, this frozen state persists even if the underlying database state changes.
Timeline of the Bug
Step 1: Offline Commit
→ SDK creates SnapshotMetadataEntity with zzl=true ✅
→ Database: pending_change_count=1 ✅
Step 2: Background Sync (Succeeds or Fails Silently)
→ Upload to server fails (confirmed via Google Takeout) ❌
→ Cached SnapshotMetadataEntity: zzl=true ❌ (NEVER UPDATED!)
Step 3: App Calls ReadCacheOrNetwork
→ Returns cached SnapshotMetadataEntity
→ hasChangePending() returns zzl=true ❌ (STALE!)
→ SDK blocks new commit
Step 4: Infinite Loop
→ Google Takeout shows: Server has STALE data ❌
→ No recovery mechanism exists
NO Workaround Exists
There is NO reliable application-level workaround:
- ❌
DataSource.ReadNetworkOnlydoesn't help (cache persists in Play Services system storage, not app cache) - ❌ App restart doesn't help
- ❌ Clear app cache/data doesn't help
- ❌ Reinstall app doesn't help
- ✅ ONLY triggering conflict from another device works (requires 2nd device!)
Important Notes
- Unity GPGS Plugin v2.0.0 does NOT expose
HasUncommittedChangesproperty - Developers cannot directly check upload status
- SDK fails silently - no error callback
- Behavior inferred from observed symptoms + reverse engineering + Google Takeout verification
Evidence
- Decompiled source code shows
private final boolean zzl(immutable, no setter) - Observed behavior matches theoretical bug pattern
- Google Takeout verification: Server data confirmed stale when bug occurs
- Google Takeout verification: Server data updates only after conflict resolution
SnapshotMetadataRefimplementation is correct (dynamic query)SnapshotMetadataEntityimplementation is broken (static immutable field)
Affected Use Cases
- ✅ Games with offline play (most mobile games)
- ✅ Auto-save implementations
- ✅ Multi-device players (tablet + phone) - CRITICAL IMPACT
- ✅ Users with intermittent connectivity
- ✅ Users who upgrade/change devices
User Impact Score
| Scenario | Impact | Risk Level |
|---|---|---|
| Single device only | Low | Progress safe locally |
| Multi-device player | CRITICAL | Permanent data loss |
| Device upgrade/replacement | HIGH | Progress not transferred |
| Device damage/loss | HIGH | All progress lost |
Related Information
This bug may be related to or affect:
- Cloud Save API caching mechanisms
- Snapshot conflict resolution flows
- Other Play Services APIs with similar caching patterns
Obfuscation Note
Field names like zzl, zza, zzb are the result of ProGuard/R8 obfuscation. Original field names are unknown, but can be inferred from @SafeParcelable.Field annotations:
zzl→hasChangePending(confirmed bygetter = "hasChangePending")zza→game(confirmed bygetter = "getGame")- etc.