From 2f7fd13ce03548788350b86364710b3f3dcffb0d Mon Sep 17 00:00:00 2001 From: phobos665 Date: Sun, 2 Aug 2026 13:47:15 +0100 Subject: [PATCH 1/5] fix(): resolve epic games multi-save --- .../service/epic/EpicCloudSavesManager.kt | 282 +++++++++++++----- .../service/epic/EpicCloudSavesTest.kt | 89 ++++++ 2 files changed, 292 insertions(+), 79 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt b/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt index 7d81ff15d1..50eac04442 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt @@ -9,6 +9,9 @@ import java.io.File import java.time.Instant import java.util.zip.GZIPInputStream import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -40,6 +43,9 @@ object EpicCloudSavesManager { private val baseCloudSyncUrl = "https://datastorage-public-service-liveegs.live.use1a.on.epicgames.com" + // Number of chunk downloads to run concurrently. + private const val MAX_PARALLEL_CHUNK_DOWNLOADS = 16 + private val httpClient = Net.http data class CloudFileInfo( @@ -159,8 +165,9 @@ object EpicCloudSavesManager { val saveDir = resolveSaveDirectory(context, game, accountId) val hasLocalFiles = saveDir?.exists() == true && (saveDir.listFiles()?.isNotEmpty() == true) - // Check cloud saves - val cloudSavesResult = listCloudSaves(game.appName, context) + // Check cloud saves (manifests only - presence of a manifest indicates cloud saves exist, + // and this avoids the 1000-entry listing cap) + val cloudSavesResult = listCloudSaves(game.appName, context, manifestsOnly = true) if (cloudSavesResult.isFailure) { Timber.tag("Epic").w("[Cloud Saves] Failed to list cloud saves, will try upload if local files exist") return@withContext if (hasLocalFiles) SyncAction.UPLOAD else SyncAction.NONE @@ -234,7 +241,15 @@ object EpicCloudSavesManager { } // List available cloud saves - private suspend fun listCloudSaves(appName: String, context: Context): Result = withContext(Dispatchers.IO) { + // The EGS metadata listing is capped at 1000 entries, so for save-heavy games the chunk + // files needed to reconstruct saves can be pushed out of the response entirely. When only + // the manifest is needed, pass manifestsOnly = true to hit the "/manifests/" sub-path, which + // returns just the manifest files and never hits the cap. + private suspend fun listCloudSaves( + appName: String, + context: Context, + manifestsOnly: Boolean = false, + ): Result = withContext(Dispatchers.IO) { try { // Get global Epic credentials (will auto-refresh if expired) val credentialsResult = EpicAuthManager.getStoredCredentials(context) @@ -246,10 +261,11 @@ object EpicCloudSavesManager { val accountId = credentials.accountId val accessToken = credentials.accessToken - Timber.tag("Epic").d("[Cloud Saves] Listing saves for $appName (account: $accountId)") + Timber.tag("Epic").d("[Cloud Saves] Listing saves for $appName (account: $accountId, manifestsOnly: $manifestsOnly)") + val pathSuffix = if (manifestsOnly) "manifests/" else "" val request = Request.Builder() - .url("$baseCloudSyncUrl/api/v1/access/egstore/savesync/$accountId/$appName/") + .url("$baseCloudSyncUrl/api/v1/access/egstore/savesync/$accountId/$appName/$pathSuffix") .header("Authorization", "Bearer $accessToken") .get() .build() @@ -347,8 +363,9 @@ object EpicCloudSavesManager { Timber.tag("Epic").i("[Cloud Saves] Found ${localFiles.size} local files") - // 2. Get cloud files and their timestamps - val cloudSavesResult = listCloudSaves(game.appName, context) + // 2. Get cloud files and their timestamps (manifests only - chunk read links are + // requested separately to bypass the 1000-entry listing cap) + val cloudSavesResult = listCloudSaves(game.appName, context, manifestsOnly = true) if (cloudSavesResult.isFailure) { Timber.tag("Epic").e("[Cloud Saves] Failed to list cloud saves") return@withContext false @@ -438,36 +455,12 @@ object EpicCloudSavesManager { if (toDownload.isNotEmpty()) { Timber.tag("Epic").i("[Cloud Saves] Downloading ${toDownload.size} files based on timestamp comparison") - // Download the required chunks and reconstruct files - val chunks = mutableMapOf() - val pathPrefix = manifestPath.split("/", limit = 4).take(3).joinToString("/") - + // Download the required chunks (parallel, with explicit read-link request) and + // reconstruct files Timber.tag("Epic").d("[Cloud Saves] Manifest path: $manifestPath") - Timber.tag("Epic").d("[Cloud Saves] Path prefix: $pathPrefix") - Timber.tag("Epic").d("[Cloud Saves] Available cloud files: ${cloudSaves.files.keys.take(10)}") - - manifest.chunkDataList?.elements?.forEach { chunkInfo -> - try { - val chunkPath = "$pathPrefix/${chunkInfo.getPath()}" - Timber.tag("Epic").d("[Cloud Saves] Looking for chunk at: $chunkPath") - val chunkFile = cloudSaves.files[chunkPath] - - if (chunkFile?.readLink == null) { - Timber.tag("Epic").w("[Cloud Saves] Chunk not found in cloud: $chunkPath") - downloadSuccess = false - return@forEach - } - - Timber.tag("Epic").d("[Cloud Saves] Downloading chunk: ${chunkInfo.getPath()}") - val chunkData = downloadFile(chunkFile.readLink) - if (chunkData.isSuccess) { - val chunkBytes = chunkData.getOrNull()!! - val decompressedData = decompressChunk(chunkBytes) - chunks[chunkInfo.guidStr] = decompressedData - } - } catch (e: Exception) { - Timber.tag("Epic").e(e, "[Cloud Saves] Error processing chunk: ${chunkInfo.getPath()}") - } + val chunks = downloadChunksParallel(context, game.appName, manifest) + if (chunks.size < (manifest.chunkDataList?.elements?.size ?: 0)) { + downloadSuccess = false } // Reconstruct only the files we need to download @@ -543,8 +536,9 @@ object EpicCloudSavesManager { return@withContext false } - // 2. List cloud saves - val cloudSavesResult = listCloudSaves(game.appName, context) + // 2. List cloud saves (manifests only - chunk read links are requested separately to + // bypass the 1000-entry listing cap that breaks save-heavy games) + val cloudSavesResult = listCloudSaves(game.appName, context, manifestsOnly = true) if (cloudSavesResult.isFailure) { Timber.tag("Epic").e("[Cloud Saves] Failed to list saves: ${cloudSavesResult.exceptionOrNull()?.message}") return@withContext false @@ -596,36 +590,8 @@ object EpicCloudSavesManager { Timber.tag("Epic").i("[Cloud Saves] Manifest parsed: ${manifest.fileManifestList?.elements?.size ?: 0} files") - // 7. Download chunks referenced in manifest - val chunks = mutableMapOf() - val pathPrefix = manifestPath.split("/", limit = 4).take(3).joinToString("/") - - manifest.chunkDataList?.elements?.forEach { chunkInfo -> - try { - // Get chunk path using ChunkInfo's getPath method - val chunkPath = "$pathPrefix/${chunkInfo.getPath()}" - val chunkFile = cloudSaves.files[chunkPath] - - if (chunkFile?.readLink == null) { - Timber.tag("Epic").w("[Cloud Saves] Chunk not found in cloud: $chunkPath") - return@forEach - } - - Timber.tag("Epic").d("[Cloud Saves] Downloading chunk: ${chunkInfo.getPath()}") - val chunkData = downloadFile(chunkFile.readLink) - if (chunkData.isSuccess) { - // Decompress and extract chunk data - val chunkBytes = chunkData.getOrNull()!! - val decompressedData = decompressChunk(chunkBytes) - chunks[chunkInfo.guidStr] = decompressedData - Timber.tag("Epic").d("[Cloud Saves] Chunk downloaded: ${chunkInfo.guidStr} (${decompressedData.size} bytes)") - } else { - Timber.tag("Epic").e("[Cloud Saves] Failed to download chunk: ${chunkInfo.getPath()}") - } - } catch (e: Exception) { - Timber.tag("Epic").e(e, "[Cloud Saves] Error processing chunk: ${chunkInfo.getPath()}") - } - } + // 7. Download chunks referenced in manifest (parallel, with explicit read-link request) + val chunks = downloadChunksParallel(context, game.appName, manifest) if (chunks.isEmpty()) { Timber.tag("Epic").e("[Cloud Saves] No chunks were downloaded, aborting") @@ -871,6 +837,170 @@ object EpicCloudSavesManager { } } + // Request read links for specific files + // + // Uses the same POST endpoint as requestWriteLinks, but reads the "readLink" field. This lets + // us fetch download links for an explicit list of chunk paths, bypassing the 1000-entry cap on + // the GET listing. File names must be the relative chunk paths (ChunkInfo.getPath()), matching + // the keys used on the upload side. + private suspend fun requestReadLinks( + context: Context, + appName: String, + fileNames: List, + ): Map = withContext(Dispatchers.IO) { + try { + val credentialsResult = EpicAuthManager.getStoredCredentials(context) + if (credentialsResult.isFailure) { + return@withContext emptyMap() + } + + val credentials = credentialsResult.getOrNull()!! + val accountId = credentials.accountId + val accessToken = credentials.accessToken + + Timber.tag("Epic").d("[Cloud Saves] Requesting read links for ${fileNames.size} files") + + val requestJson = JSONObject().apply { + put("files", JSONArray(fileNames)) + } + val requestBody = requestJson.toString() + + val request = Request.Builder() + .url("$baseCloudSyncUrl/api/v1/access/egstore/savesync/$accountId/$appName/") + .header("Authorization", "Bearer $accessToken") + .header("Content-Type", "application/json") + .post(requestBody.toRequestBody("application/json".toMediaType())) + .build() + + val response = httpClient.newCall(request).execute() + + val responseBody = try { + response.body?.string() ?: "" + } catch (e: Exception) { + Timber.tag("Epic").e(e, "[Cloud Saves] Failed to read read-links response body") + "" + } + + response.close() + + if (!response.isSuccessful) { + Timber.tag("Epic").e("[Cloud Saves] Failed to request read links: ${response.code}") + Timber.tag("Epic").e("[Cloud Saves] Response body: $responseBody") + return@withContext emptyMap() + } + + try { + val json = JSONObject(responseBody.ifEmpty { "{}" }) + val filesJson = json.optJSONObject("files") ?: JSONObject() + + val readLinks = mutableMapOf() + filesJson.keys().forEach { key -> + val fileJson = filesJson.getJSONObject(key) + val readLink = fileJson.optString("readLink") + if (readLink.isNotEmpty()) { + readLinks[key] = readLink + } + } + + Timber.tag("Epic").i("[Cloud Saves] Received ${readLinks.size} read links") + readLinks + } catch (e: Exception) { + Timber.tag("Epic").e(e, "[Cloud Saves] Failed to parse read links response") + Timber.tag("Epic").e("[Cloud Saves] Response was: $responseBody") + emptyMap() + } + } catch (e: Exception) { + Timber.tag("Epic").e(e, "[Cloud Saves] Failed to request read links") + emptyMap() + } + } + + /** + * Download and decompress all chunks referenced by [manifest], returning a map of + * chunk GUID (guidStr) -> decompressed chunk bytes. + * + * Read links are requested explicitly for the manifest's chunk paths (bypassing the + * 1000-entry listing cap), then downloaded in parallel with per-chunk retry on transient + * failures. + */ + private suspend fun downloadChunksParallel( + context: Context, + appName: String, + manifest: EpicManifest, + ): Map = withContext(Dispatchers.IO) { + val chunkInfos = manifest.chunkDataList?.elements ?: return@withContext emptyMap() + if (chunkInfos.isEmpty()) return@withContext emptyMap() + + // Request read links for the exact chunk paths the manifest references. + val chunkPaths = chunkInfos.map { it.getPath() } + val readLinks = requestReadLinks(context, appName, chunkPaths) + + if (readLinks.size < chunkPaths.size) { + Timber.tag("Epic").w( + "[Cloud Saves] Expected ${chunkPaths.size} chunk links, found ${readLinks.size} - save may be incomplete", + ) + } + + // Download in parallel with a bounded per-host dispatcher; default client throttles to 5/host. + val parallelClient = Net.httpForParallelDownloads(MAX_PARALLEL_CHUNK_DOWNLOADS) + + val results = coroutineScope { + chunkInfos.map { chunkInfo -> + async { + val readLink = readLinks[chunkInfo.getPath()] + if (readLink == null) { + Timber.tag("Epic").w("[Cloud Saves] No read link for chunk: ${chunkInfo.getPath()}") + return@async null + } + val data = downloadChunkWithRetry(parallelClient, readLink) + if (data == null) { + Timber.tag("Epic").e("[Cloud Saves] Failed to download chunk after retries: ${chunkInfo.getPath()}") + null + } else { + chunkInfo.guidStr to decompressChunk(data) + } + } + }.awaitAll() + } + + val chunks = results.filterNotNull().toMap() + Timber.tag("Epic").i("[Cloud Saves] Downloaded ${chunks.size}/${chunkInfos.size} chunks") + chunks + } + + // Download a single chunk with retry on transient failures. + private suspend fun downloadChunkWithRetry( + client: okhttp3.OkHttpClient, + readLink: String, + maxAttempts: Int = 3, + ): ByteArray? = withContext(Dispatchers.IO) { + var attempt = 0 + while (attempt < maxAttempts) { + attempt++ + try { + val request = Request.Builder().url(readLink).get().build() + client.newCall(request).execute().use { response -> + if (response.isSuccessful) { + val data = response.body?.bytes() + if (data != null && data.isNotEmpty()) { + return@withContext data + } + Timber.tag("Epic").w("[Cloud Saves] Empty chunk response (attempt $attempt/$maxAttempts)") + } else { + Timber.tag("Epic").w("[Cloud Saves] Chunk download failed: ${response.code} (attempt $attempt/$maxAttempts)") + } + } + } catch (e: Exception) { + Timber.tag("Epic").w(e, "[Cloud Saves] Chunk download error (attempt $attempt/$maxAttempts)") + } + if (attempt < maxAttempts) { + // Small linear backoff before retrying. + kotlinx.coroutines.delay(100L * attempt) + } + } + null + } + // Upload a single file private suspend fun uploadFile(writeLink: String, data: ByteArray): Result = withContext(Dispatchers.IO) { try { @@ -1043,8 +1173,7 @@ object EpicCloudSavesManager { val shaHash = java.security.MessageDigest.getInstance("SHA-1").digest(paddedData) val rollingHash = calculateRollingHash(paddedData) - // Compute groupNum exactly as Legendary does: - // group_num = crc32(struct.pack(' i = (i>>1) ^ poly else i >>= 1 + * CRC-64-ECMA variant lookup table, polynomial 0xC96C5795D7870F42. + * For each seed byte, 8 rounds of: if bit 0 set -> (v >> 1) ^ poly, else v >> 1. */ private val ROLLING_HASH_TABLE: LongArray = run { val poly = 0xC96C5795D7870F42uL @@ -1140,9 +1266,7 @@ object EpicCloudSavesManager { } /** - * Epic Games rolling hash — exact port of Legendary's get_hash() in rolling_hash.py: - * h = 0 - * for each byte i: h = ((h << 1 | h >> 63) ^ table[data[i]]) & 0xffffffffffffffff + * Epic Games' rolling hash: h = ((h << 1) | (h >> 63)) ^ table[byte], folded over the data. */ internal fun calculateRollingHash(data: ByteArray): ULong { var h = 0uL @@ -1354,7 +1478,7 @@ object EpicCloudSavesManager { } /** - * Decompress a binary chunk file — matches Legendary's Chunk.read() + Chunk.data property. + * Decompress a binary chunk file. * * Header layout (little-endian): * magic(4) + headerVersion(4) + headerSize(4) + compressedSize(4) diff --git a/app/src/test/java/app/gamenative/service/epic/EpicCloudSavesTest.kt b/app/src/test/java/app/gamenative/service/epic/EpicCloudSavesTest.kt index 5ff138932d..e6347b874d 100644 --- a/app/src/test/java/app/gamenative/service/epic/EpicCloudSavesTest.kt +++ b/app/src/test/java/app/gamenative/service/epic/EpicCloudSavesTest.kt @@ -248,6 +248,95 @@ class EpicCloudSavesTest { assertEquals(chunk.groupNum, restoredChunk.groupNum) assertEquals(chunk.windowSize, restoredChunk.windowSize) assertEquals(chunk.fileSize, restoredChunk.fileSize) + + // downloadChunksParallel() computes read-link request keys from chunkInfo.getPath() on + // the *parsed* manifest, not the original in-memory chunk, so this must be stable across + // a serialize -> readAll round-trip or chunk lookups silently fail (the many-files bug). + assertEquals(chunk.getPath(), restoredChunk.getPath()) + } + + // ------------------------------------------------------------------------- + // ChunkInfo.getPath() — chunk download-path correctness & stability. + // + // EpicCloudSavesManager.downloadChunksParallel() now requests read links explicitly via + // chunkInfo.getPath() (bypassing the EGS metadata listing's 1000-entry cap, which used to + // push chunk files out of the response for save-heavy games — the "many files" bug). That + // makes getPath() the single source of truth for locating a chunk both on upload and + // download, so its exact format and stability under many chunks matters far more than before. + // ------------------------------------------------------------------------- + + @Test + fun `ChunkInfo getPath uses ChunksV4 with a groupNum subfolder for the current manifest version`() { + val chunk = ChunkInfo( + guid = intArrayOf(0x11111111, 0x22222222, 0x33333333, 0x44444444), + hash = 0xDEADBEEFCAFEBABEuL, + groupNum = 7, + ) + + assertEquals( + "ChunksV4/07/DEADBEEFCAFEBABE_11111111222222223333333344444444.chunk", + chunk.getPath(), + ) + } + + @Test + fun `ChunkInfo getPath uses hash-prefix subfolder for legacy V3 manifests with useHashPrefixForV3`() { + val chunk = ChunkInfo( + guid = intArrayOf(1, 2, 3, 4), + hash = 0xABCDEF0123456789uL, + groupNum = 42, + useHashPrefixForV3 = true, + manifestVersion = 6, // ChunksV3 range (>= 6, < 15) + ) + + assertTrue(chunk.getPath().startsWith("ChunksV3/AB/")) + } + + @Test + fun `ChunkInfo getPath falls back to groupNum subfolder for V3 without the hash-prefix flag`() { + val chunk = ChunkInfo( + guid = intArrayOf(1, 2, 3, 4), + hash = 0xABCDEF0123456789uL, + groupNum = 42, + manifestVersion = 6, + ) + + assertTrue(chunk.getPath().startsWith("ChunksV3/42/")) + } + + @Test + fun `BinaryManifest preserves distinct, stable chunk paths for many chunks`() { + // Regression test for the cloud-save "many files" bug: with the old listing-based lookup, + // save-heavy games with many chunks could have chunk files pushed past the 1000-entry cap + // and silently fail to download. The fix instead requests read links directly by + // chunkInfo.getPath() computed from the parsed manifest, so this only works if every + // chunk's path (a) survives serialize -> readAll unchanged and (b) never collides with + // another chunk's path. + val m = buildMinimalManifest() + val random = java.security.SecureRandom() + + val chunks = (0 until 250).map { i -> + ChunkInfo( + guid = IntArray(4) { random.nextInt() }, + hash = random.nextLong().toULong(), + shaHash = ByteArray(20).also { random.nextBytes(it) }, + groupNum = i % 100, + windowSize = 1024 * 1024, + fileSize = 512L, + ) + } + m.chunkDataList!!.elements.addAll(chunks) + + val restored = EpicManifest.readAll(m.serialize()) + val restoredChunks = restored.chunkDataList!!.elements + + assertEquals(chunks.size, restoredChunks.size) + + val originalPaths = chunks.map { it.getPath() } + val restoredPaths = restoredChunks.map { it.getPath() } + + assertEquals(originalPaths, restoredPaths) + assertEquals("No two chunks should collide on their download path", originalPaths.size, originalPaths.toSet().size) } @Test From 4db77bfa4ebc8688b627159d609a487297b7d35e Mon Sep 17 00:00:00 2001 From: Daniel Joyce Date: Sat, 15 Aug 2026 16:05:16 +0100 Subject: [PATCH 2/5] fix(): remove some old debug logs and adjust comments. Also fixed an issue where GOG & epic games weren't syncing on exit due to race-condition. --- .../service/epic/EpicCloudSavesManager.kt | 14 ++++---------- .../gamenative/service/epic/EpicDownloadManager.kt | 9 +-------- .../java/app/gamenative/ui/model/MainViewModel.kt | 8 ++++++-- 3 files changed, 11 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt b/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt index 50eac04442..39942f00c7 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt @@ -241,10 +241,7 @@ object EpicCloudSavesManager { } // List available cloud saves - // The EGS metadata listing is capped at 1000 entries, so for save-heavy games the chunk - // files needed to reconstruct saves can be pushed out of the response entirely. When only - // the manifest is needed, pass manifestsOnly = true to hit the "/manifests/" sub-path, which - // returns just the manifest files and never hits the cap. + // EGS metadata is capped at 1000 entires. Need to parse manifest, especially due to games that are greedy with saves. private suspend fun listCloudSaves( appName: String, context: Context, @@ -837,12 +834,7 @@ object EpicCloudSavesManager { } } - // Request read links for specific files - // - // Uses the same POST endpoint as requestWriteLinks, but reads the "readLink" field. This lets - // us fetch download links for an explicit list of chunk paths, bypassing the 1000-entry cap on - // the GET listing. File names must be the relative chunk paths (ChunkInfo.getPath()), matching - // the keys used on the upload side. + // Request read links from the manifest private suspend fun requestReadLinks( context: Context, appName: String, @@ -933,6 +925,8 @@ object EpicCloudSavesManager { // Request read links for the exact chunk paths the manifest references. val chunkPaths = chunkInfos.map { it.getPath() } + + // Grab readlinks which we'll download from val readLinks = requestReadLinks(context, appName, chunkPaths) if (readLinks.size < chunkPaths.size) { diff --git a/app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt b/app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt index b98e7b35ab..b63db66e32 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt @@ -723,8 +723,6 @@ class EpicDownloadManager @Inject constructor( expectedSize.toInt() } - Timber.tag("Epic").d("Chunk header: magic=0x${magic.toString(16)}, headerVersion=$headerVersion, headerSize=$headerSize, compressedSize=$compressedSize, uncompressedSize=$uncompressedSize, storedAs=0x${storedAs.toString(16)}, isCompressed=$isCompressed, expectedSize=$expectedSize") - outputFile.outputStream().buffered().use { output -> if (isCompressed) { // Streaming decompression @@ -733,7 +731,6 @@ class EpicDownloadManager @Inject constructor( val inputBuffer = ByteArray(65536) // 64KB compressed read buffer val outputBuffer = ByteArray(65536) // 64KB decompressed write buffer var endOfStream = false - var firstRead = true while (totalBytesWritten < uncompressedSize && !endOfStream) { // Feed more input if needed @@ -749,10 +746,6 @@ class EpicDownloadManager @Inject constructor( downloadInfo.emitProgressChange() lastProgressEmitAt = now } - if (firstRead) { - Log.d("Epic", "First compressed data bytes: ${inputBuffer.take(16).joinToString(" ") { "%02x".format(it) }}") - firstRead = false - } inflater.setInput(inputBuffer, 0, bytesRead) } } @@ -803,7 +796,7 @@ class EpicDownloadManager @Inject constructor( // Verify size if (totalBytesWritten != expectedSize) { - Timber.tag("Epic").d("Size mismatch: expected=$expectedSize, actual=$totalBytesWritten, diff=${expectedSize - totalBytesWritten}") + Timber.tag("Epic").w("Warning - Size mismatch: expected=$expectedSize, actual=$totalBytesWritten, diff=${expectedSize - totalBytesWritten}") outputFile.delete() throw Exception("Decompressed size mismatch: expected $expectedSize, got $totalBytesWritten") } diff --git a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt index 4b7fb79617..934494d19d 100644 --- a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt @@ -606,7 +606,7 @@ class MainViewModel @Inject constructor( if (gameSource == GameSource.GOG) { Timber.tag("GOG").i("[Cloud Saves] GOG Game detected for $appId — syncing cloud saves after close") - viewModelScope.launch(Dispatchers.IO) { + withContext(Dispatchers.IO) { try { Timber.tag("GOG").d("[Cloud Saves] Starting post-game upload sync for $appId") val syncSuccess = app.gamenative.service.gog.GOGService.syncCloudSaves( @@ -619,6 +619,8 @@ class MainViewModel @Inject constructor( } else { Timber.tag("GOG").w("[Cloud Saves] Upload sync failed for $appId") } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Timber.tag("GOG").e(e, "[Cloud Saves] Exception during upload sync for $appId") } @@ -628,7 +630,7 @@ class MainViewModel @Inject constructor( if (gameSource == GameSource.EPIC) { Timber.tag("Epic").i("[Cloud Saves] Epic Game detected for $appId — syncing cloud saves after close") - viewModelScope.launch(Dispatchers.IO) { + withContext(Dispatchers.IO) { try { Timber.tag("Epic").d("[Cloud Saves] Starting post-game upload sync for $gameId") val syncSuccess = EpicCloudSavesManager.syncCloudSaves( @@ -641,6 +643,8 @@ class MainViewModel @Inject constructor( } else { Timber.tag("Epic").w("[Cloud Saves] Upload sync failed for $gameId") } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Timber.tag("Epic").e(e, "[Cloud Saves] Exception during upload sync for $gameId") } From dfda9d7e9464658dd0b3fa5b0777509817f412d3 Mon Sep 17 00:00:00 2001 From: Daniel Joyce Date: Sat, 15 Aug 2026 16:17:06 +0100 Subject: [PATCH 3/5] fix(): Fix issue where epic service dies before the cloud saves could happen. --- app/src/main/java/app/gamenative/MainActivity.kt | 10 +++++++--- .../gamenative/service/epic/EpicCloudSavesManager.kt | 2 ++ .../java/app/gamenative/service/epic/EpicService.kt | 3 ++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/app/gamenative/MainActivity.kt b/app/src/main/java/app/gamenative/MainActivity.kt index c8ef8d9757..fe90b6968b 100644 --- a/app/src/main/java/app/gamenative/MainActivity.kt +++ b/app/src/main/java/app/gamenative/MainActivity.kt @@ -405,10 +405,14 @@ class MainActivity : ComponentActivity() { GOGService.stop() } - // Stop EpicService when app is destroyed (unless config change) + // Stop EpicService when app is destroyed (unless config change or a cloud save sync is in flight) if (EpicService.isRunning && !isChangingConfigurations) { - Timber.i("Stopping EpicService - app destroyed") - EpicService.stop() + if (!EpicService.hasActiveOperations()) { + Timber.i("Stopping EpicService - app destroyed") + EpicService.stop() + } else { + Timber.i("Not stopping EpicService on destroy - active operations in progress") + } } } diff --git a/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt b/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt index 39942f00c7..6177cc4363 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt @@ -36,6 +36,8 @@ object EpicCloudSavesManager { private val syncMutex = Mutex() private val activeSyncs = mutableSetOf() + fun hasActiveSyncs(): Boolean = activeSyncs.isNotEmpty() + // Data classes for API responses data class CloudSaveFiles( val files: Map, diff --git a/app/src/main/java/app/gamenative/service/epic/EpicService.kt b/app/src/main/java/app/gamenative/service/epic/EpicService.kt index 9a52f106e3..31b06e62ca 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicService.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicService.kt @@ -154,7 +154,8 @@ class EpicService : Service() { // ========================================================================== fun hasActiveOperations(): Boolean { - return syncInProgress || backgroundSyncJob?.isActive == true || hasActiveDownload() + return syncInProgress || backgroundSyncJob?.isActive == true || hasActiveDownload() || + EpicCloudSavesManager.hasActiveSyncs() } private fun setSyncInProgress(inProgress: Boolean) { From 1f491ad966d02b9085d3d356b1530a7258d3c75d Mon Sep 17 00:00:00 2001 From: Daniel Joyce Date: Sat, 15 Aug 2026 16:41:14 +0100 Subject: [PATCH 4/5] fix(): fix issue where the offline check stopped gog & epic games from uploading. --- app/src/main/java/app/gamenative/ui/model/MainViewModel.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt index 934494d19d..9ba6d28210 100644 --- a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt @@ -599,7 +599,9 @@ class MainViewModel @Inject constructor( private suspend fun handleExitCloudSync(context: Context, appId: String, gameId: Int) { val gameSource = ContainerUtils.extractGameSourceFromContainerId(appId) - if (ContainerUtils.isLocalSavesOnly(context, appId) || isOffline.value) { + // isOffline is derived from Steam's login state (see PluviaMain's startDestination / onClickPlay) + // and is meaningless for GOG/Epic, which check their own auth internally — only gate Steam on it. + if (ContainerUtils.isLocalSavesOnly(context, appId) || (gameSource == GameSource.STEAM && isOffline.value)) { Timber.tag("Exit").i("Local saves only or offline mode enabled for $appId — skipping cloud sync on exit") return } From 94ebc982cd85bd5a1ab6773867659caa0d008569 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 5 Sep 2026 15:08:15 +0530 Subject: [PATCH 5/5] Revert EpicService keep-alive on destroy; abort download when chunks are missing The exit sync runs in viewModelScope, so keeping EpicService alive past onDestroy did not save the sync when the task was swiped away. Drop that and the unsynchronized hasActiveSyncs() read it depended on. downloadSaves now fails when fewer chunks than the manifest lists were fetched, instead of writing truncated files and recording the cloud timestamp. --- app/src/main/java/app/gamenative/MainActivity.kt | 10 +++------- .../gamenative/service/epic/EpicCloudSavesManager.kt | 7 +++---- .../java/app/gamenative/service/epic/EpicService.kt | 3 +-- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/app/gamenative/MainActivity.kt b/app/src/main/java/app/gamenative/MainActivity.kt index fe90b6968b..c8ef8d9757 100644 --- a/app/src/main/java/app/gamenative/MainActivity.kt +++ b/app/src/main/java/app/gamenative/MainActivity.kt @@ -405,14 +405,10 @@ class MainActivity : ComponentActivity() { GOGService.stop() } - // Stop EpicService when app is destroyed (unless config change or a cloud save sync is in flight) + // Stop EpicService when app is destroyed (unless config change) if (EpicService.isRunning && !isChangingConfigurations) { - if (!EpicService.hasActiveOperations()) { - Timber.i("Stopping EpicService - app destroyed") - EpicService.stop() - } else { - Timber.i("Not stopping EpicService on destroy - active operations in progress") - } + Timber.i("Stopping EpicService - app destroyed") + EpicService.stop() } } diff --git a/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt b/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt index 6177cc4363..9f5cd52c88 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt @@ -36,8 +36,6 @@ object EpicCloudSavesManager { private val syncMutex = Mutex() private val activeSyncs = mutableSetOf() - fun hasActiveSyncs(): Boolean = activeSyncs.isNotEmpty() - // Data classes for API responses data class CloudSaveFiles( val files: Map, @@ -592,8 +590,9 @@ object EpicCloudSavesManager { // 7. Download chunks referenced in manifest (parallel, with explicit read-link request) val chunks = downloadChunksParallel(context, game.appName, manifest) - if (chunks.isEmpty()) { - Timber.tag("Epic").e("[Cloud Saves] No chunks were downloaded, aborting") + val expectedChunks = manifest.chunkDataList?.elements?.size ?: 0 + if (chunks.isEmpty() || chunks.size < expectedChunks) { + Timber.tag("Epic").e("[Cloud Saves] Downloaded ${chunks.size}/$expectedChunks chunks, aborting to avoid writing a truncated save") return@withContext false } diff --git a/app/src/main/java/app/gamenative/service/epic/EpicService.kt b/app/src/main/java/app/gamenative/service/epic/EpicService.kt index 31b06e62ca..9a52f106e3 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicService.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicService.kt @@ -154,8 +154,7 @@ class EpicService : Service() { // ========================================================================== fun hasActiveOperations(): Boolean { - return syncInProgress || backgroundSyncJob?.isActive == true || hasActiveDownload() || - EpicCloudSavesManager.hasActiveSyncs() + return syncInProgress || backgroundSyncJob?.isActive == true || hasActiveDownload() } private fun setSyncInProgress(inProgress: Boolean) {