From 5021ecbe04be935aa652c61433ca7234c0fa4112 Mon Sep 17 00:00:00 2001 From: Jeremy Bernstein Date: Wed, 15 Apr 2026 22:08:32 +0200 Subject: [PATCH] fix: skip spurious conflict when cache lost but local==remote destructive db migration wipes file_change_lists cache, making every steam game trigger conflict on first launch post-update. check if local state is byte-identical to remote manifest (by filename + SHA) before declaring conflict; if so, rehydrate cache and report UpToDate silently. also populates real timestamps on the genuine-divergence path (was showing epoch). test: dbCleared_localMatchesRemote_rehydratesSilently_noConflict --- .../app/gamenative/service/SteamAutoCloud.kt | 47 +++++++++-- .../gamenative/service/SteamAutoCloudTest.kt | 80 +++++++++++++++++++ 2 files changed, 119 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt index 3dc36c84bd..b9853b306b 100644 --- a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt +++ b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt @@ -881,21 +881,52 @@ object SteamAutoCloud { }.inWholeMicroseconds val hasUncachedLocalFiles = cacheIsAbsentOrEmpty && allLocalUserFiles.isNotEmpty() + var rehydratedSilently = false if (hasUncachedLocalFiles) { - if (localAppChangeNumber < 0) { - // first offline play: never synced, no cache, but local - // files exist — cloud would silently overwrite them - hasLocalChanges = true - conflictUfsVersion = CURRENT_UFS_PARSE_VERSION + // no cache but local files exist. before declaring conflict, + // check if local state is byte-identical to remote — this is + // the "cache-wiped by destructive migration, nothing actually + // changed" case and should be silent. key by absolute filesystem + // path: cloud stores files as (pathPrefixIndex, basename) while + // local scan stores filename as subdir-relative path with a + // single pattern prefix, so basename-only keys won't match for + // nested files. + // windows paths are case-insensitive; steam cloud and wine may + // disagree on case. lowercase the keys so content-identical + // files compare equal regardless. + val localByPath = allLocalUserFiles.associate { + it.getAbsPath(prefixToPath).toString().lowercase() to it.sha + } + val remoteByPath = appFileListChange.files.associate { + getFullFilePath(it, appFileListChange).toString().lowercase() to it.shaFile + } + val localMatchesRemote = localByPath.keys == remoteByPath.keys && + localByPath.all { (path, sha) -> + sha.contentEquals(remoteByPath[path]) + } + + if (localMatchesRemote) { + Timber.i("Cache absent but local matches remote — rehydrating cache silently") + with(steamInstance) { + db.withTransaction { + fileChangeListsDao.insert(appInfo.id, allLocalUserFiles) + changeNumbersDao.insert(appInfo.id, cloudAppChangeNumber) + } + } + syncResult = SyncResult.UpToDate + filesManaged = allLocalUserFiles.size + rehydratedSilently = true } else { - // app update cleared the cache but a prior sync was - // recorded — can't tell if local files changed hasLocalChanges = true conflictUfsVersion = CURRENT_UFS_PARSE_VERSION + remoteTimestamp = appFileListChange.files.map { it.timestamp.time }.maxOrNull() ?: 0L + localTimestamp = allLocalUserFiles.map { it.timestamp }.maxOrNull() ?: 0L } } - if (!hasLocalChanges) { + if (rehydratedSilently) { + // nothing to do — cache is now consistent with cloud + } else if (!hasLocalChanges) { // we can safely download the new changes since no changes have been // made locally diff --git a/app/src/test/java/app/gamenative/service/SteamAutoCloudTest.kt b/app/src/test/java/app/gamenative/service/SteamAutoCloudTest.kt index dab9727fac..2d0b45f934 100644 --- a/app/src/test/java/app/gamenative/service/SteamAutoCloudTest.kt +++ b/app/src/test/java/app/gamenative/service/SteamAutoCloudTest.kt @@ -2168,5 +2168,85 @@ class SteamAutoCloudTest { assertEquals(SyncResult.Success, result!!.syncResult) assertTrue("Should have downloaded files", result.filesDownloaded > 0) } + + // ── Scenario 16: DB cache wiped by destructive migration, local == remote ── + // Repro of the "every steam game reports conflict post-update" bug. When + // fallbackToDestructiveMigration wipes file_change_lists but local save files + // are byte-identical to the cloud manifest, we must rehydrate the cache + // silently and NOT show a conflict dialog. + @Test + fun dbCleared_localMatchesRemote_rehydratesSilently_noConflict() = runBlocking { + db.appChangeNumbersDao().deleteByAppId(steamAppId) + db.appFileChangeListsDao().deleteByAppId(steamAppId) + + // the 5 files created in setUp() — cloud manifest must match exactly. + // local scan basePath = %WinMyDocuments%/My Games/TestGame/Steam/{id}, + // files live under SaveGames/ → filename (relativized) includes that prefix. + val localFiles = mapOf( + "SaveGames/AutoSaveData.sav" to "autosave content".toByteArray(), + "SaveGames/SaveData_0.sav" to "savedata0 content".toByteArray(), + "SaveGames/ContinueSaveData.sav" to "continue content".toByteArray(), + "SaveGames/SaveData_1.sav" to "savedata1 content".toByteArray(), + "SaveGames/SystemData_0.sav" to "systemdata content".toByteArray(), + ) + + assertTrue("Precondition: local save files exist", saveFilesDir.listFiles()!!.isNotEmpty()) + + val cloudFiles = localFiles.map { (name, content) -> + val m = mock() + whenever(m.filename).thenReturn(name) + whenever(m.shaFile).thenReturn(sha1(content)) + whenever(m.pathPrefixIndex).thenReturn(0) + whenever(m.timestamp).thenReturn(Date()) + whenever(m.rawFileSize).thenReturn(content.size) + m + } + + val cloudFileChangeList = makeCloudFileChangeList( + cloudChangeNumber = 5, + files = cloudFiles, + pathPrefixes = listOf("%WinMyDocuments%/My Games/TestGame/Steam/76561198025127569"), + ) + every { mockSteamCloud.getAppFileListChange(any(), any(), any()) } returns + CompletableFuture.completedFuture(cloudFileChangeList) + + val testApp = db.steamAppDao().findApp(steamAppId)!! + val result = SteamAutoCloud.syncUserFiles( + appInfo = testApp, + clientId = clientId, + steamInstance = mockSteamService, + steamCloud = mockSteamCloud, + preferredSave = SaveLocation.None, + prefixToPath = makePrefixToPath(), + ).await() + + assertNotNull(result) + assertEquals( + "local matches remote exactly — should be UpToDate, not Conflict", + SyncResult.UpToDate, + result!!.syncResult, + ) + assertNull( + "no conflict dialog — conflictUfsVersion must be null", + result.conflictUfsVersion, + ) + assertEquals("no downloads", 0, result.filesDownloaded) + assertEquals("no uploads", 0, result.filesUploaded) + + // cache must be rehydrated so next launch doesn't trip the same path + val rehydrated = db.appFileChangeListsDao().getByAppId(steamAppId) + assertNotNull("cache should be rehydrated", rehydrated) + assertEquals( + "rehydrated cache should contain all 5 local files", + 5, + rehydrated!!.userFileInfo.size, + ) + val rehydratedCn = db.appChangeNumbersDao().getByAppId(steamAppId) + assertEquals( + "rehydrated change number should match cloud", + 5L, + rehydratedCn!!.changeNumber, + ) + } }