Fix store retry lifecycle, resume checks, and Epic progress

This commit is contained in:
Juan Marti Mercado committed 2026-10-13 19:15:34 -04:00
1 parent c8a49e853a
commit 93cd54c93b
12 files changed
+344 -45

No files matched your search

@@ -53,10 +53,11 @@ object StoreInstallRoot {
/** Pre-install storage check to detect insufficient space before starting. */
fun checkStorage(context: Context, item: CatalogItem, targetRoot: File): StorageCheck {
val size = estimatedBytesNeeded(item)
val freeTarget = availableBytes(targetRoot)
// Epic's manifest delta accounts for completed files and reusable chunks on resume.
val existing = existingFolder(context, item.store, item.id, item.title)
val freeTarget = availableBytes(existing ?: targetRoot)
// Backends account for completed files on resume; the catalog cannot describe remaining work.
// Comparing the full catalog size to remaining free space would reject valid resumes.
if (item.store == Store.EPIC) return StorageCheck(enough = true, requiredBytes = size, availableBytes = freeTarget)
if (item.store == Store.EPIC || existing != null) return StorageCheck(enough = true, requiredBytes = size, availableBytes = freeTarget)
if (size <= 0L) return StorageCheck(enough = true, requiredBytes = 0L, availableBytes = freeTarget)
if (freeTarget in 1 until size) {
val msg = context.getString(com.droiddeck.launcher.R.string.stores_storage_insufficient, formatBytes(size), formatBytes(freeTarget))
@@ -219,11 +219,11 @@ object StoresState {
/** Starts an install into [root] (the internal root when null). */
fun install(context: Context, item: CatalogItem, root: java.io.File? = null) {
val app = context.applicationContext
val target = root ?: StoreInstallRoot.installRoot(app)
val folder = StoreInstallRoot.folderFor(app, item.store, item.id, item.title, root ?: StoreInstallRoot.installRoot(app))
val target = folder.parentFile!!.parentFile!!
val check = StoreInstallRoot.checkStorage(app, item, target)
if (!check.enough) {
val errorMsg = check.errorMessage ?: app.getString(com.droiddeck.launcher.R.string.stores_storage_insufficient, formatBytes(check.requiredBytes), formatBytes(check.availableBytes))
val folder = StoreInstallRoot.folderFor(app, item.store, item.id, item.title, target)
val entry = com.droiddeck.launcher.stores.download.DownloadEntry(
item.store,
item.id,
@@ -234,13 +234,7 @@ object StoresState {
)
com.droiddeck.launcher.stores.download.SavedQueue.remember(app, item, target)
com.droiddeck.launcher.stores.download.DownloadQueue.recordFailed(app, entry, errorMsg) {
object : com.droiddeck.launcher.stores.download.DownloadQueue.DownloadJob {
override fun run(handle: com.droiddeck.launcher.stores.download.DownloadQueue.JobHandle): String? {
install(app, item, target)
return null
}
override fun cancel(deleteFiles: Boolean) {}
}
install(app, item, target)
}
return
}
@@ -77,9 +77,10 @@ public final class AmazonDownloadManager {
if (manifestBytes == null) throw new InstallException("The manifest could not be downloaded");
AmazonManifest.ParsedManifest manifest = AmazonManifest.parse(manifestBytes);
cb.onLog("amazon: " + manifest.allFiles.size() + " files, " + fmt(manifest.totalInstallSize));
long remainingBytes = remainingInstallBytes(manifest.allFiles, installDir);
long usable = installDir.getUsableSpace();
if (manifest.totalInstallSize > 0 && usable > 0 && manifest.totalInstallSize > usable)
throw new InstallException("Not enough free space: need " + fmt(manifest.totalInstallSize) + ", only " + fmt(usable) + " free");
if (remainingBytes > 0 && usable > 0 && remainingBytes > usable)
throw new InstallException("Not enough free space: need " + fmt(remainingBytes) + ", only " + fmt(usable) + " free");
cb.onSizes(manifest.totalInstallSize, manifest.totalInstallSize);
cb.onBytes(0, manifest.totalInstallSize, 0);
if (cancel.get()) return null;
@@ -184,7 +185,7 @@ public final class AmazonDownloadManager {
AtomicLong lastSpeedMs, AtomicLong lastSpeedBytes, AtomicLong speedBps, Callback cb, AtomicBoolean cancel) {
File destFile = new File(installDir, file.unixPath());
File tmpFile = new File(installDir, file.unixPath() + ".tmp");
if (destFile.exists() && destFile.length() == file.size) { totalDownloaded.addAndGet(file.size); return true; }
if (fileComplete(file, destFile)) { totalDownloaded.addAndGet(file.size); return true; }
String fileUrl = AmazonApiClient.appendPath(baseUrl, "files/" + file.hashHex());
for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) {
destFile.getParentFile().mkdirs();
@@ -256,6 +257,21 @@ public final class AmazonDownloadManager {
public static boolean isInstalled(File installDir) { return new File(installDir, COMPLETE_MARKER).exists(); }
static long remainingInstallBytes(List<AmazonManifest.ManifestFile> files, File installDir) {
long remaining = 0;
for (AmazonManifest.ManifestFile file : files) {
if (!fileComplete(file, new File(installDir, file.unixPath()))) remaining += file.size;
}
return remaining;
}
private static boolean fileComplete(AmazonManifest.ManifestFile file, File output) {
if (!output.isFile() || output.length() != file.size) return false;
if (file.hashAlgorithm != 0 || file.hashBytes.length == 0) return true;
try { return Arrays.equals(sha256(output), file.hashBytes); }
catch (Exception e) { return false; }
}
private static byte[] sha256(File file) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (FileInputStream fis = new FileInputStream(file)) {
@@ -128,7 +128,7 @@ object DownloadQueue {
}
}
private class Item(var entry: DownloadEntry, val factory: () -> DownloadJob, var job: DownloadJob? = null, val cancelled: AtomicBoolean = AtomicBoolean(false), var pauseRequested: Boolean = false) {
private class Item(var entry: DownloadEntry, val factory: (() -> DownloadJob)? = null, val retry: (() -> Unit)? = null, var job: DownloadJob? = null, val cancelled: AtomicBoolean = AtomicBoolean(false), var pauseRequested: Boolean = false) {
/** The shown speed's state: the last sample's clock and bytes, and the running average (bytes/s). */
var speedAt = 0L
var speedBytes = 0L
@@ -186,11 +186,13 @@ object DownloadQueue {
}
/** Immediately records a failed download (e.g. from pre-flight storage check). */
fun recordFailed(context: Context, entry: DownloadEntry, error: String, factory: (() -> DownloadJob)? = null) {
fun recordFailed(context: Context, entry: DownloadEntry, error: String, retry: (() -> Unit)? = null) {
val app = context.applicationContext
val now = System.currentTimeMillis()
synchronized(lock) {
if (items[entry.key]?.entry?.isActive == true) return
items.remove(entry.key)
heldPaused.remove(entry.key)
items[entry.key] = Item(
entry.copy(
state = DownloadState.FAILED,
@@ -200,10 +202,7 @@ object DownloadQueue {
startedAt = now,
finishedAt = now
),
factory ?: { object : DownloadJob {
override fun run(handle: JobHandle): String? = null
override fun cancel(deleteFiles: Boolean) {}
} }
retry = retry
)
renumber()
publishLocked()
@@ -229,14 +228,21 @@ object DownloadQueue {
fun resume(context: Context, key: String) {
val app = context.applicationContext
val retry: (() -> Unit)?
synchronized(lock) {
val item = items[key] ?: return
if (item.entry.state != DownloadState.PAUSED && item.entry.state != DownloadState.FAILED) return
retry = item.retry
if (retry == null && item.factory == null) return
items.remove(key)
items[key] = Item(item.entry.copy(state = DownloadState.QUEUED, speedBps = 0, etaSeconds = -1, error = null, startedAt = System.currentTimeMillis()), item.factory)
if (retry == null) {
items[key] = Item(item.entry.copy(state = DownloadState.QUEUED, speedBps = 0, etaSeconds = -1, error = null, startedAt = System.currentTimeMillis()), item.factory)
}
renumber()
publishLocked()
}
// A preflight retry rechecks storage and enqueues the real backend, never a running placeholder.
if (retry != null) { retry(); return }
StoresState.logLine("resumed \"${entry(key)?.name}\"")
advance(app)
}
@@ -261,7 +267,7 @@ object DownloadQueue {
item.job?.cancel(deleteFiles = true)
} else {
// Never started: nothing is on disk, but the job knows where it would have gone.
Thread({ runCatching { item.factory().cancel(deleteFiles = true) } }, "store-dl-cancel").start()
Thread({ runCatching { item.factory?.invoke()?.cancel(deleteFiles = true) } }, "store-dl-cancel").start()
}
StoresState.logLine("cancelled \"${item.entry.name}\"")
StoreDownloadService.finish(app, key)
@@ -278,7 +284,7 @@ object DownloadQueue {
val item = synchronized(lock) { items[key] } ?: return
if (item.entry.state == DownloadState.RUNNING || item.entry.state == DownloadState.QUEUED) return
if (item.entry.state == DownloadState.FAILED || item.entry.state == DownloadState.PAUSED) {
Thread({ runCatching { item.factory().cancel(deleteFiles = true) } }, "store-dl-clear").start()
Thread({ runCatching { item.factory?.invoke()?.cancel(deleteFiles = true) } }, "store-dl-clear").start()
}
synchronized(lock) { items.remove(key); publishLocked() }
StoreDownloadService.finish(context.applicationContext, key)
@@ -373,7 +379,7 @@ object DownloadQueue {
var result: String? = null
var error: String? = null
try {
val job = item.factory()
val job = checkNotNull(item.factory).invoke()
synchronized(lock) { item.job = job }
StoresState.logLine("${item.entry.store.label}: fetching manifest for \"${item.entry.name}\"")
result = job.run(handle)
@@ -60,7 +60,7 @@ public final class EpicDownloadManager {
* {@code itemsTotal} the files, when it counts files.
*/
default void onStage(String stage, long done, long total, int items, int itemsTotal) {}
/** What this run fetches (compressed) and what the game takes on disk. */
/** What this run counts as work (file bytes written) and what the game takes on disk. */
default void onSizes(long downloadBytes, long diskBytes) {}
}
@@ -230,18 +230,14 @@ public final class EpicDownloadManager {
if (freeGame > 0 && pendingBytes > freeGame) throw new InstallException("Not enough free space: need " + fmt(pendingBytes) + ", only " + fmt(freeGame) + " free");
}
cb.onLog("epic: cache " + (cacheOnInstallVolume ? "beside the game" : "scratch") + ", peak game additional=" + fmt(peakGameAdditional) + (cacheOnInstallVolume ? "" : ", peak cache additional=" + fmt(peakCacheAdditional)) + " for " + fmt(pendingBytes) + " of files");
long totalBytes = 0;
for (ChunkInfo c : needed) totalBytes += Math.max(c.fileSize, 1);
final long fTotalBytes = totalBytes;
final long fTotalBytes = pendingBytes;
final int totalChunks = needed.size();
cb.onSizes(fTotalBytes, planned);
cb.onBytes(0, fTotalBytes, 0);
cb.onLog("epic: " + fmt(fTotalBytes) + " in " + totalChunks + " chunks");
cb.onLog("epic: " + fmt(fTotalBytes) + " to write using " + totalChunks + " unique chunks");
cb.onLog("epic: engine=bounded-java (shared path; native availability does not change cache semantics)");
Map<String, Integer> remainingRefs = chunkReferences(pending);
Set<String> creditedChunks = new LinkedHashSet<>();
long completedBytes = 0, writtenBytes = 0;
long fetchedBytes = 0;
long transferStarted = System.nanoTime();
int completedCount = 0, doneFiles = 0, totalFiles = pending.size();
for (FileInfo file : pending) {
@@ -271,16 +267,13 @@ public final class EpicDownloadManager {
throw new InstallException("Failed to download Epic chunk " + guid);
}
cachedChunkBytes.put(guid, cachedChunk.length());
fetchedBytes += Math.max(chunk.fileSize, 1);
}
if (creditedChunks.add(guid)) {
completedBytes += Math.max(chunk.fileSize, 1);
completedCount++;
cb.onProgress("Downloading chunks (" + completedCount + "/" + totalChunks + ")", (int) (completedBytes * 80L / Math.max(1, fTotalBytes)));
}
long elapsedMs = Math.max(1, (System.nanoTime() - transferStarted) / 1_000_000);
cb.onBytes(completedBytes, fTotalBytes, fetchedBytes * 1000 / elapsedMs);
completedCount++;
cb.onProgress("Downloading chunks (part " + completedCount + ")", (int) (completedBytes * 80L / Math.max(1, fTotalBytes)));
copyChunkPart(cachedChunk, part, bos, fileSha, cancel, relPath);
completedBytes += part.size & 0xFFFFFFFFL;
long elapsedMs = Math.max(1, (System.nanoTime() - transferStarted) / 1_000_000);
cb.onBytes(completedBytes, fTotalBytes, completedBytes * 1000 / elapsedMs);
Integer refs = remainingRefs.get(guid);
if (refs != null) {
if (refs <= 1) {
@@ -189,11 +189,12 @@ public final class GogDownloadManager {
installPath.mkdirs();
long plannedBytes = 0;
for (DepotFile df : files) plannedBytes += df.totalSize;
if (plannedBytes > 0) {
long remainingBytes = remainingInstallBytes(files, installPath);
if (remainingBytes > 0) {
long usable = installPath.getUsableSpace();
if (usable > 0 && plannedBytes > usable) {
if (usable > 0 && remainingBytes > usable) {
out.hardStop = true;
out.error = "Not enough free space: need " + formatBytes(plannedBytes) + ", only " + formatBytes(usable) + " free";
out.error = "Not enough free space: need " + formatBytes(remainingBytes) + ", only " + formatBytes(usable) + " free";
return out;
}
}
@@ -1018,7 +1019,16 @@ public final class GogDownloadManager {
for (File f : files) if (f.isDirectory()) collectExeRecursive(f, out);
}
private static final class DepotFile {
static long remainingInstallBytes(List<DepotFile> files, File installPath) {
long remaining = 0;
for (DepotFile file : files) {
File output = new File(installPath, file.relativePath);
if (!fileVerified(output, file.totalSize, file.md5)) remaining += file.totalSize;
}
return remaining;
}
static final class DepotFile {
final String relativePath;
final List<ChunkRef> chunks = new ArrayList<>();
String md5;
@@ -11,6 +11,7 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import org.robolectric.shadows.ShadowStatFs
/** Where a store game goes: internal storage unless the user picks a card, and back to its own folder on a rerun. */
@RunWith(RobolectricTestRunner::class)
@@ -80,4 +81,27 @@ class StoreInstallRootTest {
assertTrue(check.enough)
assertEquals(Long.MAX_VALUE, check.requiredBytes)
}
@Test fun gogAndAmazonResumeChecksUseTheExistingVolumeAndDeferRemainingWork() {
val card = File(app.filesDir, "resume-card").apply { mkdirs() }
SessionPrefs.setGameStorage(app, card.path, "Card")
for (store in listOf(Store.GOG, Store.AMAZON)) {
val item = CatalogItem(store, "resume-${store.id}", "Partial ${store.id}", imageUrl = null, sizeBytes = Long.MAX_VALUE)
val folder = File(StoreInstallRoot.storeDir(File(card, "Games"), store), StoreInstallRoot.folderName(item.title, item.id)).apply { mkdirs() }
StoreInstalls.begin(folder, store, item.id, item.title, null, null)
val check = StoreInstallRoot.checkStorage(app, item, StoreInstallRoot.internalRoot(app))
assertTrue(check.enough)
assertEquals(StoreInstallRoot.availableBytes(folder), check.availableBytes)
assertEquals(folder, StoreInstallRoot.folderFor(app, store, item.id, item.title))
}
}
@Test fun freshGogAndAmazonStillRejectInsufficientCatalogSpace() {
StoreInstallRoot.internalRoot(app).mkdirs()
ShadowStatFs.registerStats(StoreInstallRoot.internalRoot(app).path, 1024, 100, 100)
for (store in listOf(Store.GOG, Store.AMAZON)) {
val item = CatalogItem(store, "too-large-${store.id}", "Too large ${store.id}", imageUrl = null, sizeBytes = Long.MAX_VALUE)
assertFalse(StoreInstallRoot.checkStorage(app, item, StoreInstallRoot.internalRoot(app)).enough)
}
}
}
@@ -0,0 +1,29 @@
package com.droiddeck.launcher.stores.amazon
import java.io.File
import java.security.MessageDigest
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class AmazonResumeSpaceTest {
@Test fun remainingSpaceExcludesVerifiedFilesButIncludesCorruptAndPartialFiles() {
val root = File(RuntimeEnvironment.getApplication().filesDir, "amazon-resume").apply { mkdirs() }
val data = byteArrayOf(1, 2, 3, 4)
val hash = MessageDigest.getInstance("SHA-256").digest(data)
val files = listOf("good", "corrupt", "partial", "missing").map {
AmazonManifest.ManifestFile(it, 4, 0, hash)
}
File(root, "good").writeBytes(data)
File(root, "corrupt").writeBytes(ByteArray(4))
File(root, "partial").writeBytes(byteArrayOf(1))
assertEquals(12L, AmazonDownloadManager.remainingInstallBytes(files, root))
for (file in files) File(root, file.unixPath()).writeBytes(data)
assertEquals(0L, AmazonDownloadManager.remainingInstallBytes(files, root))
}
}
@@ -0,0 +1,140 @@
package com.droiddeck.launcher.stores.download
import android.content.Context
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.stores.*
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import org.junit.After
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.json.JSONArray
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import org.robolectric.shadows.ShadowStatFs
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class DownloadQueueRetryTest {
private val app get() = RuntimeEnvironment.getApplication()
@After fun cleanRows() { DownloadQueue.dismissFinished(); StoreSizes.known.clear() }
private fun awaitFailure(key: String, error: String) {
val until = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
while (System.nanoTime() < until) {
val entry = DownloadQueue.entry(key)
if (entry?.state == DownloadState.FAILED && entry.error == error) return
Thread.sleep(10)
}
fail("Expected failed real job: ${DownloadQueue.entry(key)}")
}
@Test fun storageRetryRechecksThenRunsTheRealBackendAndNormalRetryStillWorks() {
StoreInstallRoot.internalRoot(app).mkdirs()
ShadowStatFs.registerStats(StoreInstallRoot.internalRoot(app).path, 1024, 100, 100)
val item = CatalogItem(Store.GOG, "preflight-retry", "Preflight retry", imageUrl = null, sizeBytes = Long.MAX_VALUE)
val runs = AtomicInteger()
var lastTarget: File? = null
val previous = StoresState.backend(Store.GOG)
val backend = object : StoreBackend {
override val store = Store.GOG
override fun signIn(context: Context) {}
override fun signOut(context: Context) {}
override fun syncLibrary(context: Context, force: Boolean) {}
override fun loadShelves(context: Context, force: Boolean) {}
override fun uninstall(context: Context, game: InstalledStoreGame) {}
override fun install(context: Context, item: CatalogItem, root: File) {
lastTarget = root
DownloadQueue.enqueue(context, DownloadEntry(item.store, item.id, item.title)) {
object : DownloadQueue.DownloadJob {
override fun run(handle: DownloadQueue.JobHandle): String? {
throw IllegalStateException("backend-${runs.incrementAndGet()}")
}
override fun cancel(deleteFiles: Boolean) {}
}
}
}
}
StoresState.register(backend)
try {
StoresState.install(app, item)
assertEquals(DownloadState.FAILED, DownloadQueue.entry(item.key)?.state)
DownloadQueue.retry(app, item.key)
assertEquals(DownloadState.FAILED, DownloadQueue.entry(item.key)?.state)
assertEquals(0, runs.get())
StoreSizes.known[item.key] = 1
DownloadQueue.retry(app, item.key)
awaitFailure(item.key, "backend-1")
DownloadQueue.retry(app, item.key)
awaitFailure(item.key, "backend-2")
DownloadQueue.clear(app, item.key)
assertNull(DownloadQueue.entry(item.key))
// A card resume (including the restored paused path) must retain its real target.
val card = File(app.filesDir, "retry-card").apply { mkdirs() }
SessionPrefs.setGameStorage(app, card.path, "Card")
val target = File(card, "Games")
val folder = File(StoreInstallRoot.storeDir(target, item.store), StoreInstallRoot.folderName(item.title, item.id)).apply { mkdirs() }
StoreInstalls.begin(folder, item.store, item.id, item.title, null, null)
StoreSizes.known[item.key] = Long.MAX_VALUE
StoresState.unfinished = StoreInstallRoot.unfinished(app)
DownloadQueue.holdPaused(item.key)
StoresState.requestInstall(app, item)
assertEquals(target, lastTarget)
assertEquals(DownloadState.PAUSED, DownloadQueue.entry(item.key)?.state)
assertEquals(2, runs.get())
val saved = File(app.filesDir, "store-queue.json")
val until = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
var correctSavedRoot = false
while (!correctSavedRoot && System.nanoTime() < until) {
correctSavedRoot = runCatching {
val rows = JSONArray(saved.readText())
(0 until rows.length()).any { i ->
val row = rows.getJSONObject(i)
row.getJSONObject("item").getString("id") == item.id &&
row.getString("root") == target.absolutePath && row.getBoolean("paused")
}
}.getOrDefault(false)
if (!correctSavedRoot) Thread.sleep(10)
}
assertTrue("Paused saved queue must remember the existing card root", correctSavedRoot)
DownloadQueue.clear(app, item.key)
} finally {
if (previous != null) StoresState.register(previous)
SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "")
StoresState.unfinished = emptyList()
}
}
@Test fun preflightClearRemovesTheRowWithoutRunningItsRetry() {
val entry = DownloadEntry(Store.AMAZON, "clear-preflight", "Clear")
DownloadQueue.recordFailed(app, entry, "storage") { fail("Clear must not retry") }
DownloadQueue.clear(app, entry.key)
assertNull(DownloadQueue.entry(entry.key))
}
@Test fun heldPausedBackendRestoresAndResumesWithoutAPlaceholder() {
val entry = DownloadEntry(Store.GOG, "restored-paused", "Paused")
val ran = CountDownLatch(1)
DownloadQueue.holdPaused(entry.key)
DownloadQueue.enqueue(app, entry) {
object : DownloadQueue.DownloadJob {
override fun run(handle: DownloadQueue.JobHandle): String? {
ran.countDown()
throw IllegalStateException("restored")
}
override fun cancel(deleteFiles: Boolean) {}
}
}
assertEquals(DownloadState.PAUSED, DownloadQueue.entry(entry.key)?.state)
assertEquals(1L, ran.count)
DownloadQueue.resume(app, entry.key)
assertTrue(ran.await(5, TimeUnit.SECONDS))
awaitFailure(entry.key, "restored")
}
}
@@ -29,7 +29,7 @@ class EpicBoundedInstallTest {
private lateinit var root: File
private val requests = AtomicInteger()
private val reject = AtomicBoolean()
private val bytes = ByteArray(256 * 1024) { (it % 251).toByte() }
private var bytes = ByteArray(256 * 1024) { (it % 251).toByte() }
@Before fun setUp() {
root = temp.newFolder("game")
@@ -178,6 +178,49 @@ class EpicBoundedInstallTest {
assertFalse(events.drop(firstInstall).contains("download"))
}
@Test fun sharedChunksDoNotCompleteProgressBeforeTheRemainingFilesAreWritten() {
val samples = mutableListOf<Pair<Long, Long>>()
val callback = object : EpicDownloadManager.Callback {
override fun onProgress(message: String, pct: Int) {}
override fun onBytes(done: Long, total: Long, speedBps: Long) { samples.add(done to total) }
override fun onStage(stage: String, done: Long, total: Long, items: Int, itemsTotal: Int) {
if (stage == "stream" && items < itemsTotal) assertTrue(samples.last().first < samples.last().second)
}
}
assertNotNull(install(callback = callback))
assertEquals(listOf(0L, bytes.size / 2L, bytes.size.toLong()), samples.map { it.first })
assertTrue(samples.all { it.second == bytes.size.toLong() })
}
@Test fun evictedSharedChunksAdvanceWriteProgressOnEveryRefetchWithoutChangingStages() {
bytes = ByteArray((EpicDownloadManager.MAX_RETAINED_CHUNK_CACHE_BYTES + 1024).toInt()) { (it % 251).toByte() }
manifest.uniqueChunks.single().apply {
windowSize = bytes.size
fileSize = bytes.size.toLong()
sha1 = sha(bytes)
}
manifest.files = (0..2).map { gameFile("part-$it.bin", it * 1024, 1024) }
val samples = mutableListOf<Long>()
val stages = mutableListOf<String>()
val callback = object : EpicDownloadManager.Callback {
override fun onProgress(message: String, pct: Int) {}
override fun onBytes(done: Long, total: Long, speedBps: Long) {
assertEquals(3072L, total)
assertFalse(stages.contains("install"))
samples.add(done)
}
override fun onStage(stage: String, done: Long, total: Long, items: Int, itemsTotal: Int) {
stages.add(stage)
if (stage == "stream" && items < itemsTotal) assertTrue(samples.last() < total)
}
}
assertNotNull(install(callback = callback))
assertEquals(3, requests.get())
assertEquals(listOf(0L, 1024L, 2048L, 3072L), samples)
assertEquals("install", stages.last())
repeat(3) { assertArrayEquals(bytes.copyOfRange(it * 1024, (it + 1) * 1024), File(root, "part-$it.bin").readBytes()) }
}
@Test fun hogwartsSizedSequentialPlanFitsAvailableSpaceWithoutFullChunkCache() {
val gib = 1024L * 1024 * 1024
val file = EpicDownloadManager.FileInfo()
@@ -0,0 +1,29 @@
package com.droiddeck.launcher.stores.gog
import java.io.File
import java.security.MessageDigest
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class GogResumeSpaceTest {
@Test fun remainingSpaceExcludesVerifiedFilesButIncludesCorruptAndPartialFiles() {
val root = File(RuntimeEnvironment.getApplication().filesDir, "gog-resume").apply { mkdirs() }
val data = byteArrayOf(1, 2, 3, 4)
val hash = MessageDigest.getInstance("MD5").digest(data).joinToString("") { "%02x".format(it) }
val files = listOf("good", "corrupt", "partial", "missing").map {
GogDownloadManager.DepotFile(it).apply { totalSize = 4; md5 = hash }
}
File(root, "good").writeBytes(data)
File(root, "corrupt").writeBytes(ByteArray(4))
File(root, "partial").writeBytes(byteArrayOf(1))
assertEquals(12L, GogDownloadManager.remainingInstallBytes(files, root))
for (file in files) File(root, file.relativePath).writeBytes(data)
assertEquals(0L, GogDownloadManager.remainingInstallBytes(files, root))
}
}
+14
View File
@@ -28,6 +28,14 @@ An Epic install on a card keeps its in-flight chunks in `cacheDir/stores/epic/<i
`""` keeps the cache beside the game, as an internal install has it. The scratch folder is
removed on cancel-with-delete and on uninstall.
Preflight resolves an existing game's folder before selecting its target root, so a resume or
repair on a card checks and remembers that volume rather than internal storage. Fresh GOG and
Amazon installs can reject insufficient catalog-size space immediately; existing installs defer
to manifest checks of the files still needing work (GOG MD5, Amazon SHA-256 where supplied).
Preflight failures remain failed Downloads rows with Clear and Retry. Retry reruns preflight
outside the worker lifecycle and then enqueues the real backend; it never occupies a queue slot
with a placeholder job. Active queue persistence and ordinary backend pause/retry are unchanged.
Epic installs use the same bounded Java writer whether the native engine is available or not.
It fetches one chunk at a time, verifies it, and writes the required parts into the current
game file. Chunks are deleted after their last use; reusable chunks are retained up to 64 MiB,
@@ -36,6 +44,12 @@ downloaded again. This trades parallel-download throughput and potentially extra
traffic for bounded temporary disk usage. The native batch APIs remain available but are not
used by this install path.
The bounded Epic download bar, measured rate and ETA count pending file bytes written, not unique
compressed chunks fetched. Shared chunks therefore cannot complete the bar before later files
are written, and an evicted chunk's refetch is reflected in the time spent doing that work.
Streaming stays in the Download stage until all files are written and verified, then enters
Install for registration; it does not bounce between stages for each file.
The space check follows the manifest delta pass and simulates file writes, chunk fetches,
and evictions, rather than adding the entire cache to the entire game. Completed files and
verified cached chunks are accounted for on resume. On a card, game and cache requirements