mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Fix store retry lifecycle, resume checks, and Epic progress
This commit is contained in:
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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user