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..9f5cd52c88 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,12 @@ object EpicCloudSavesManager { } // List available cloud saves - private suspend fun listCloudSaves(appName: String, context: Context): Result = withContext(Dispatchers.IO) { + // 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, + manifestsOnly: Boolean = false, + ): Result = withContext(Dispatchers.IO) { try { // Get global Epic credentials (will auto-refresh if expired) val credentialsResult = EpicAuthManager.getStoredCredentials(context) @@ -246,10 +258,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 +360,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 +452,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 +533,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,39 +587,12 @@ 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("/") + // 7. Download chunks referenced in manifest (parallel, with explicit read-link request) + val chunks = downloadChunksParallel(context, game.appName, manifest) - 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()}") - } - } - - 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 } @@ -871,6 +835,167 @@ object EpicCloudSavesManager { } } + // Request read links from the manifest + 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() } + + // Grab readlinks which we'll download from + 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 +1168,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 +1261,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 +1473,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/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..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,14 +599,16 @@ 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 } 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 +621,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 +632,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 +645,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") } 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