Preserve Epic files during replacement and distinguish full storage

This commit is contained in:
Juan Marti Mercado committed 2026-10-13 19:15:36 -04:00
1 parent 87e6556993
commit 05d1795a51
14 files changed
+416 -102

No files matched your search

@@ -90,6 +90,7 @@ class StoreGameSidecar(
const val VERSION = 1
const val STATE_INSTALLED = "installed"
const val STATE_INSTALLING = "installing"
const val STATE_UPDATING = "updating"
fun file(folder: File): File = File(folder, FILE_NAME)
@@ -20,6 +20,7 @@ import java.util.zip.CRC32
* shortcut's path - and with it its appid - stays the same from one session to the next.
*/
object StoreInstallRoot {
const val SPACE_UNKNOWN = -1L
private const val INTERNAL = "root/Games/Stores"
private const val ON_VOLUME = "Games"
const val GUEST_EXTERNAL = "/mnt/droiddeck-stores"
@@ -36,29 +37,30 @@ object StoreInstallRoot {
val errorMessage: String? = null
)
fun availableBytes(file: File): Long {
@JvmStatic
fun availableBytes(file: File): Long = probeAvailableBytes(file, { StatFs(it.path).availableBytes }, { it.usableSpace })
internal fun probeAvailableBytes(file: File, statBytes: (File) -> Long, usableBytes: (File) -> Long): Long {
var dir: File? = if (file.isDirectory) file else file.parentFile
if (dir == null) dir = file
while (dir != null && !dir.exists()) dir = dir.parentFile
val existing = dir ?: return SPACE_UNKNOWN
return try {
var dir: File? = if (file.isDirectory) file else file.parentFile
if (dir == null) dir = file
while (dir != null && !dir.exists()) dir = dir.parentFile
val existing = dir
if (existing == null) {
StoresState.logLine("storage: no existing ancestor for store install target")
return usableSpaceOrUnknown(file)
}
StatFs(existing.path).availableBytes
statBytes(existing).coerceAtLeast(SPACE_UNKNOWN)
} catch (e: Exception) {
StoresState.logLine("storage: StatFs failed for store install target (${e.javaClass.simpleName})")
usableSpaceOrUnknown(file)
try {
// File's zero also means an unavailable measurement; only StatFs establishes zero.
usableBytes(existing).takeIf { it > 0L } ?: SPACE_UNKNOWN
} catch (fallback: Exception) {
StoresState.logLine("storage: usable-space fallback failed for store install target (${fallback.javaClass.simpleName})")
SPACE_UNKNOWN
}
}
}
private fun usableSpaceOrUnknown(file: File): Long = try {
file.usableSpace
} catch (e: Exception) {
StoresState.logLine("storage: usable-space fallback failed for store install target (${e.javaClass.simpleName})")
0L
}
@JvmStatic
fun insufficientSpace(required: Long, available: Long): Boolean = required > 0L && available >= 0L && available < required
/**
* The catalog size is the known minimum for an install. Epic's actual chunk-cache requirement
@@ -75,7 +77,7 @@ object StoreInstallRoot {
// Comparing the full catalog size to remaining free space would reject valid resumes.
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) {
if (insufficientSpace(size, freeTarget)) {
val msg = context.getString(com.droiddeck.launcher.R.string.stores_storage_insufficient, formatBytes(size), formatBytes(freeTarget))
return StorageCheck(enough = false, requiredBytes = size, availableBytes = freeTarget, errorMessage = msg)
}
@@ -7,6 +7,8 @@ import com.droiddeck.launcher.frontend.AddedGames
import com.droiddeck.launcher.frontend.LibraryCache
import com.droiddeck.launcher.frontend.Library
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
/**
* What happens around a store install so the rest of the app sees the game: the sidecar is
@@ -30,10 +32,28 @@ object StoreInstalls {
* updated keeps its finished sidecar, so the game stays in Steam meanwhile.
*/
fun begin(folder: File, store: Store, id: String, title: String, cover: String?, hero: String?) {
if (StoreGameSidecar.read(folder)?.isInstalled == true) return
val before = StoreGameSidecar.read(folder)
if (before?.isInstalled == true || before?.state == StoreGameSidecar.STATE_UPDATING) return
StoreGameSidecar(store, id, title, exe = "", cover = cover, hero = hero, state = StoreGameSidecar.STATE_INSTALLING).write(folder)
}
/** Epic commits verified files individually, not a whole-game transaction. Keep updates resumable. */
@JvmStatic
fun markUpdating(folder: File) {
val before = StoreGameSidecar.read(folder) ?: return
if (!before.isInstalled) return
val stage = File.createTempFile(".droiddeck-update-", ".stage", folder)
try {
java.io.FileOutputStream(stage).use {
it.write(before.copy(state = StoreGameSidecar.STATE_UPDATING, installVersion = "").toJson().toString(2).toByteArray(Charsets.UTF_8))
it.fd.sync()
}
Files.move(stage.toPath(), StoreGameSidecar.file(folder).toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING)
} finally {
stage.delete()
}
}
/**
* Finishes an install: writes the launcher and the finished sidecar, then the art, then
* registers the game. Runs on the caller's worker thread. The launcher and sidecar writes
@@ -105,16 +125,18 @@ object StoreInstalls {
/**
* What Cancel does: everything the install wrote goes - the game folder with its sidecar and
* the caches in [caches] - unless the folder holds a finished install being repaired or updated;
* then only the caches go and the working game stays. Off the caller's thread; the library
* follows when it is done, so the game reads Install again.
* then only the caches go. An Epic `updating` folder is retained too: its verified swaps are
* not rolled back, and it must be resumed before being advertised as installed again.
* Off the caller's thread; the library follows when it is done.
*/
fun discard(context: Context, folder: File, caches: List<File>) {
val app = context.applicationContext
Thread({
val keep = StoreGameSidecar.read(folder)?.isInstalled == true
val sidecar = StoreGameSidecar.read(folder)
val keep = sidecar?.isInstalled == true || sidecar?.state == StoreGameSidecar.STATE_UPDATING
caches.forEach { deleteTree(it) }
if (!keep) deleteTree(folder)
StoresState.logLine(if (keep) "removed the partial update of ${folder.name}" else "removed ${folder.name}")
StoresState.logLine(if (keep) "removed update caches for ${folder.name}; verified file swaps are retained" else "removed ${folder.name}")
StoresState.notifyLibraryChanged(app)
}, "store-cancel-clean").start()
}
@@ -78,8 +78,8 @@ public final class AmazonDownloadManager {
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 (remainingBytes > 0 && usable > 0 && remainingBytes > usable)
long usable = com.droiddeck.launcher.stores.StoreInstallRoot.availableBytes(installDir);
if (com.droiddeck.launcher.stores.StoreInstallRoot.insufficientSpace(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);
@@ -4,6 +4,8 @@ import android.content.Context;
import android.util.Log;
import com.droiddeck.launcher.stores.StoreLog;
import com.droiddeck.launcher.stores.StoreInstallRoot;
import com.droiddeck.launcher.stores.StoreInstalls;
import org.json.JSONArray;
import org.json.JSONObject;
@@ -19,6 +21,8 @@ import java.net.URL;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Iterator;
@@ -205,36 +209,27 @@ public final class EpicDownloadManager {
if (!chunkCacheDir.isDirectory() && !chunkCacheDir.mkdirs()) throw new InstallException("Could not create Epic chunk cache");
Map<String, Long> cachedChunkBytes = prepareBoundedCache(chunkCacheDir, needed);
long pendingBytes = 0;
long reclaimableBytes = 0;
Map<String, Long> oldFileBytes = new LinkedHashMap<>();
for (FileInfo f : pending) {
pendingBytes += f.fileSize();
File output = new File(installDir, f.filename.replace("\\", "/"));
if (output.isFile()) reclaimableBytes += output.length();
if (output.isFile()) oldFileBytes.put(f.filename, output.length());
}
boolean cacheOnInstallVolume = cachePath.isEmpty();
long peakGameAdditional = cacheOnInstallVolume
? estimatePeakAdditionalBytes(pending, chunksByGuid, cachedChunkBytes, true)
: pendingBytes;
? estimatePeakAdditionalBytes(pending, chunksByGuid, cachedChunkBytes, true, oldFileBytes)
: estimateStagedFilePeak(pending, oldFileBytes);
long peakCacheAdditional = cacheOnInstallVolume
? 0 : estimatePeakAdditionalBytes(pending, chunksByGuid, cachedChunkBytes, false);
peakGameAdditional = Math.max(0, peakGameAdditional - reclaimableBytes);
long freeGame = installDir.getUsableSpace();
long freeGame = StoreInstallRoot.availableBytes(installDir);
if (cacheOnInstallVolume) {
if (freeGame > 0 && peakGameAdditional > freeGame) throw new InstallException("Not enough free space for the bounded Epic install: need up to " + fmt(peakGameAdditional) + " additional, only " + fmt(freeGame) + " free");
if (StoreInstallRoot.insufficientSpace(peakGameAdditional, freeGame)) throw new InstallException("Not enough free space for safe staged Epic replacement: need up to " + fmt(peakGameAdditional) + " additional, only " + fmt(freeGame) + " free");
} else {
long freeCache = chunkCacheDir.getUsableSpace();
if (freeCache > 0 && peakCacheAdditional > freeCache) throw new InstallException("Not enough internal space for the bounded Epic download cache: need up to " + fmt(peakCacheAdditional) + " additional, only " + fmt(freeCache) + " free");
if (freeGame > 0 && peakGameAdditional > freeGame) throw new InstallException("Not enough free space: need " + fmt(peakGameAdditional) + ", only " + fmt(freeGame) + " free");
long freeCache = StoreInstallRoot.availableBytes(chunkCacheDir);
if (StoreInstallRoot.insufficientSpace(peakCacheAdditional, freeCache)) throw new InstallException("Not enough internal space for the bounded Epic download cache: need up to " + fmt(peakCacheAdditional) + " additional, only " + fmt(freeCache) + " free");
if (StoreInstallRoot.insufficientSpace(peakGameAdditional, freeGame)) throw new InstallException("Not enough free space for safe staged Epic replacement: need " + fmt(peakGameAdditional) + ", only " + fmt(freeGame) + " free");
}
if (cancel.get()) return null;
// Reclaim invalid/old outputs only after both volume checks accept the update.
// The peak estimate assumes these bytes are released before replacement writes.
for (FileInfo f : pending) {
File output = new File(installDir, f.filename.replace("\\", "/"));
File parent = output.getParentFile();
if (parent != null) parent.mkdirs();
if (output.isFile() && !output.delete() && output.exists()) throw new InstallException("Could not replace incomplete file " + output.getName());
}
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");
final long fTotalBytes = pendingBytes;
final int totalChunks = needed.size();
@@ -246,61 +241,70 @@ public final class EpicDownloadManager {
long completedBytes = 0, writtenBytes = 0;
long transferStarted = System.nanoTime();
int completedCount = 0, doneFiles = 0, totalFiles = pending.size();
Set<String> reservedPaths = new LinkedHashSet<>();
for (FileInfo file : manifest.files) reservedPaths.add(new File(installDir, file.filename.replace("\\", "/")).getCanonicalPath());
boolean updateRecorded = false;
for (FileInfo file : pending) {
if (cancel.get()) return null;
String relPath = file.filename.replace("\\", "/");
File outFile = new File(installDir, relPath);
cb.onProgress("Downloading chunks: " + (relPath.contains("/") ? relPath.substring(relPath.lastIndexOf('/') + 1) : relPath), (int) (completedBytes * 80L / Math.max(1, fTotalBytes)));
// Like the native assembler, write to the final path and remove it on failure.
// A sibling "<name>.part" can itself be a legitimate file in the manifest.
File partFile = outFile;
MessageDigest fileSha = expectedDigest(file.sha1);
try (FileOutputStream fos = new FileOutputStream(partFile); BufferedOutputStream bos = new BufferedOutputStream(fos, 65536)) {
for (ChunkPart part : file.parts) {
if (cancel.get()) throw new IOException("cancelled");
String guid = part.guidStr();
ChunkInfo chunk = chunksByGuid.get(guid);
if (chunk == null) throw new InstallException("Missing chunk metadata " + guid + " for " + relPath);
File cachedChunk = new File(chunkCacheDir, guid);
if (!cachedChunk.isFile() || !isValidCachedChunk(chunk, cachedChunk)) {
if (cachedChunk.exists() && !cachedChunk.delete() && cachedChunk.exists()) {
throw new InstallException("Could not clear invalid Epic chunk " + guid);
File partFile = createStagingFile(outFile, reservedPaths);
try {
try (FileOutputStream fos = new FileOutputStream(partFile); BufferedOutputStream bos = new BufferedOutputStream(fos, 65536)) {
for (ChunkPart part : file.parts) {
if (cancel.get()) throw new IOException("cancelled");
String guid = part.guidStr();
ChunkInfo chunk = chunksByGuid.get(guid);
if (chunk == null) throw new InstallException("Missing chunk metadata " + guid + " for " + relPath);
File cachedChunk = new File(chunkCacheDir, guid);
if (!cachedChunk.isFile() || !isValidCachedChunk(chunk, cachedChunk)) {
if (cachedChunk.exists() && !cachedChunk.delete() && cachedChunk.exists()) {
throw new InstallException("Could not clear invalid Epic chunk " + guid);
}
cachedChunkBytes.remove(guid);
cachedChunk = new File(chunkCacheDir, guid);
if (!downloadChunkStreaming(chunk, manifest.chunkDir, cdnUrls, cachedChunk, cancel)) {
if (cancel.get()) throw new IOException("cancelled");
throw new InstallException("Failed to download Epic chunk " + guid);
}
cachedChunkBytes.put(guid, cachedChunk.length());
}
cachedChunkBytes.remove(guid);
cachedChunk = new File(chunkCacheDir, guid);
if (!downloadChunkStreaming(chunk, manifest.chunkDir, cdnUrls, cachedChunk, cancel)) {
if (cancel.get()) throw new IOException("cancelled");
throw new InstallException("Failed to download Epic chunk " + guid);
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);
if (consumeChunkReference(remainingRefs, guid)) {
if (cachedChunk.exists() && !cachedChunk.delete() && cachedChunk.exists()) {
throw new InstallException("Could not release consumed Epic chunk " + guid);
}
cachedChunkBytes.remove(guid);
}
cachedChunkBytes.put(guid, cachedChunk.length());
trimCacheToBound(chunkCacheDir, cachedChunkBytes);
}
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);
if (consumeChunkReference(remainingRefs, guid)) {
if (cachedChunk.exists() && !cachedChunk.delete() && cachedChunk.exists()) {
throw new InstallException("Could not release consumed Epic chunk " + guid);
}
cachedChunkBytes.remove(guid);
}
trimCacheToBound(chunkCacheDir, cachedChunkBytes);
bos.flush();
fos.getFD().sync();
}
bos.flush();
} catch (IOException e) {
partFile.delete();
if (cancel.get()) return null;
throw new InstallException("Could not assemble " + relPath + ": " + e.getMessage());
} catch (InstallException e) {
if (partFile.length() != file.fileSize()) throw new InstallException("Size mismatch for " + relPath);
if (fileSha != null && !MessageDigest.isEqual(fileSha.digest(), file.sha1)) {
throw new InstallException("SHA-1 mismatch for " + relPath);
}
// Once a swap can mix versions, the old installed record is no longer truthful.
if (!updateRecorded) {
StoreInstalls.markUpdating(installDir);
updateRecorded = true;
}
if (cancel.get()) return null;
Files.move(partFile.toPath(), outFile.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
if (cancel.get()) return null;
throw new InstallException("Could not assemble or atomically replace " + relPath + ": " + e.getMessage());
} finally {
partFile.delete();
throw e;
}
if (cancel.get()) { partFile.delete(); return null; }
if (fileSha != null && !MessageDigest.isEqual(fileSha.digest(), file.sha1)) {
partFile.delete();
throw new InstallException("SHA-1 mismatch for " + relPath);
}
doneFiles++;
writtenBytes += file.fileSize();
@@ -318,6 +322,27 @@ public final class EpicDownloadManager {
}
}
private static File createStagingFile(File output, Set<String> reservedPaths) throws IOException {
File parent = output.getParentFile();
if (!parent.isDirectory() && !parent.mkdirs()) throw new IOException("Could not create output directory");
// Unique sibling staging is on the output volume, never a manifest's literal "<name>.part".
while (true) {
File stage = File.createTempFile(".droiddeck-epic-", ".stage", parent);
if (!reservedPaths.contains(stage.getCanonicalPath())) return stage;
if (!stage.delete()) throw new IOException("Could not clear staging collision");
}
}
static long estimateStagedFilePeak(List<FileInfo> files, Map<String, Long> oldFileBytes) {
long delta = 0, peak = 0;
for (FileInfo file : files) {
delta += file.fileSize();
peak = Math.max(peak, delta);
delta -= oldFileBytes.containsKey(file.filename) ? oldFileBytes.get(file.filename) : 0;
}
return peak;
}
/**
* Discard abandoned/orphaned cache entries and retain only verified chunks this install uses.
* A bounded run consumes each chunk at its last part reference, so stale chunks from an older
@@ -423,9 +448,17 @@ public final class EpicDownloadManager {
* Simulates the serial fetch → append part → last-reference eviction order. The returned
* additional-space peak is measured against the current free-space reading: existing verified
* chunks are already reflected in that reading and count as reclaimed only when actually freed.
* Old output bytes are reclaimed after the complete new file is staged and verified, never
* before its parts are written. The same schedule is used for scratch-cache game-volume checks.
*/
static long estimatePeakAdditionalBytes(List<FileInfo> files, Map<String, ChunkInfo> chunksByGuid,
Map<String, Long> cachedChunkBytes, boolean includeFileWrites) {
return estimatePeakAdditionalBytes(files, chunksByGuid, cachedChunkBytes, includeFileWrites, new LinkedHashMap<>());
}
static long estimatePeakAdditionalBytes(List<FileInfo> files, Map<String, ChunkInfo> chunksByGuid,
Map<String, Long> cachedChunkBytes, boolean includeFileWrites,
Map<String, Long> oldFileBytes) {
Map<String, Integer> refs = chunkReferences(files);
Map<String, Long> residentChunks = new LinkedHashMap<>(cachedChunkBytes);
long residentDelta = 0, peak = 0;
@@ -451,6 +484,8 @@ public final class EpicDownloadManager {
}
for (long bytes : evictOldestReusableChunks(residentChunks).values()) residentDelta -= bytes;
}
// The verified staging file coexists with the old output until its atomic swap.
if (includeFileWrites) residentDelta -= oldFileBytes.containsKey(file.filename) ? oldFileBytes.get(file.filename) : 0;
}
return Math.max(0, peak);
}
@@ -191,8 +191,8 @@ public final class GogDownloadManager {
for (DepotFile df : files) plannedBytes += df.totalSize;
long remainingBytes = remainingInstallBytes(files, installPath);
if (remainingBytes > 0) {
long usable = installPath.getUsableSpace();
if (usable > 0 && remainingBytes > usable) {
long usable = com.droiddeck.launcher.stores.StoreInstallRoot.availableBytes(installPath);
if (com.droiddeck.launcher.stores.StoreInstallRoot.insufficientSpace(remainingBytes, usable)) {
out.hardStop = true;
out.error = "Not enough free space: need " + formatBytes(remainingBytes) + ", only " + formatBytes(usable) + " free";
return out;
@@ -74,8 +74,10 @@ internal fun InstallWhereDialog(item: CatalogItem, onDismiss: () -> Unit) {
targets.forEachIndexed { i, t ->
val needed = com.droiddeck.launcher.stores.StoreInstallRoot.estimatedBytesNeeded(item)
val freeBytes = t.freeBytes
val isLow = needed > 0 && freeBytes in 1 until needed
val freeText = if (isLow) {
val isLow = com.droiddeck.launcher.stores.StoreInstallRoot.insufficientSpace(needed, freeBytes)
val freeText = if (freeBytes < 0L) {
stringResource(R.string.stores_target_free_unknown)
} else if (isLow) {
stringResource(R.string.stores_storage_low, formatBytes(freeBytes), formatBytes(needed))
} else {
stringResource(R.string.stores_target_free, formatBytes(freeBytes))
@@ -513,8 +513,10 @@ private fun InstallWhere(item: CatalogItem) {
targets.forEachIndexed { i, t ->
val needed = com.droiddeck.launcher.stores.StoreInstallRoot.estimatedBytesNeeded(item)
val freeBytes = t.freeBytes
val isLow = needed > 0 && freeBytes in 1 until needed
val trailingText = if (isLow) {
val isLow = com.droiddeck.launcher.stores.StoreInstallRoot.insufficientSpace(needed, freeBytes)
val trailingText = if (freeBytes < 0L) {
stringResource(R.string.stores_target_free_unknown)
} else if (isLow) {
stringResource(R.string.stores_storage_low, formatBytes(freeBytes), formatBytes(needed))
} else {
stringResource(R.string.stores_target_free, formatBytes(freeBytes))
+1
View File
@@ -2257,6 +2257,7 @@
<string name="stores_install_title">Install %1$s:</string>
<string name="stores_target_internal">Internal</string>
<string name="stores_target_free">%1$s free</string>
<string name="stores_target_free_unknown">Free space unknown</string>
<string name="stores_storage_insufficient">Not enough free space: need %1$s, only %2$s free</string>
<string name="stores_storage_low">Insufficient space (%1$s free, need %2$s)</string>
<string name="stores_notification_channel">Store downloads</string>
@@ -113,4 +113,34 @@ class StoreInstallRootTest {
assertFalse(StoreInstallRoot.checkStorage(app, item, StoreInstallRoot.internalRoot(app)).enough)
}
}
@Test fun measuredZeroRejectsFreshGogAndAmazonRatherThanBeingUnknown() {
val root = File(app.filesDir, "zero-space").apply { mkdirs() }
ShadowStatFs.registerStats(root.path, 1024, 0, 0)
val missing = File(root, "not-created/game")
assertEquals(0L, StoreInstallRoot.availableBytes(missing))
assertFalse(File(root, "not-created").exists())
for (store in listOf(Store.GOG, Store.AMAZON)) {
val item = CatalogItem(store, "zero-${store.id}", "Zero ${store.id}", imageUrl = null, sizeBytes = 1L)
val check = StoreInstallRoot.checkStorage(app, item, missing)
assertFalse(check.enough)
assertEquals(0L, check.availableBytes)
}
}
@Test fun failedProbesAreUnknownAndDoNotCreateOrWriteToTargets() {
val root = File(app.filesDir, "read-only-probe").apply { mkdirs() }
val missing = File(root, "not-created/game")
val fail: (File) -> Long = { throw java.io.IOException("unavailable") }
assertEquals(-1L, StoreInstallRoot.probeAvailableBytes(missing, fail, fail))
assertEquals(-1L, StoreInstallRoot.probeAvailableBytes(missing, fail, { 0L }))
assertEquals(42L, StoreInstallRoot.probeAvailableBytes(missing, fail, { 42L }))
assertEquals(0L, StoreInstallRoot.probeAvailableBytes(missing, { 0L }, fail))
assertFalse(File(root, "not-created").exists())
assertTrue(root.listFiles().orEmpty().isEmpty())
assertFalse(StoreInstallRoot.insufficientSpace(1L, -1L))
assertTrue(StoreInstallRoot.insufficientSpace(1L, 0L))
assertFalse(StoreInstallRoot.insufficientSpace(1L, 1L))
assertFalse(StoreInstallRoot.insufficientSpace(0L, 0L))
}
}
@@ -3,15 +3,29 @@ package com.droiddeck.launcher.stores.amazon
import java.io.File
import java.security.MessageDigest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import org.robolectric.shadows.ShadowStatFs
import com.droiddeck.launcher.stores.StoreInstallRoot
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class AmazonResumeSpaceTest {
@Test fun remainingWorkRejectsMeasuredZeroButNotUnknownSpace() {
val root = File(RuntimeEnvironment.getApplication().filesDir, "amazon-zero").apply { mkdirs() }
ShadowStatFs.registerStats(root.path, 1024, 0, 0)
val files = listOf(AmazonManifest.ManifestFile("missing", 4, 0, null))
val needed = AmazonDownloadManager.remainingInstallBytes(files, root)
assertEquals(4L, needed)
assertTrue(StoreInstallRoot.insufficientSpace(needed, StoreInstallRoot.availableBytes(root)))
assertFalse(StoreInstallRoot.insufficientSpace(needed, StoreInstallRoot.SPACE_UNKNOWN))
}
@Test fun remainingSpaceExcludesVerifiedFilesButIncludesCorruptAndPartialFiles() {
val root = File(RuntimeEnvironment.getApplication().filesDir, "amazon-resume").apply { mkdirs() }
val data = byteArrayOf(1, 2, 3, 4)
@@ -1,5 +1,8 @@
package com.droiddeck.launcher.stores.epic
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstalls
import java.io.File
import java.net.InetAddress
import java.net.ServerSocket
@@ -18,6 +21,7 @@ import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.shadows.ShadowStatFs
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
@@ -33,6 +37,7 @@ class EpicBoundedInstallTest {
@Before fun setUp() {
root = temp.newFolder("game")
ShadowStatFs.registerStats(root.path, 1_000_000_000, 1_000_000_000, 1_000_000_000)
server = ServerSocket(0, 8, InetAddress.getByName("127.0.0.1"))
worker = Thread {
while (!server.isClosed) {
@@ -91,6 +96,145 @@ class EpicBoundedInstallTest {
EpicDownloadManager.Callback { _, _ -> }, cache: String = "") =
EpicDownloadManager.installManifest(manifest, root.path, cache, null, cancel, callback)
private fun oldInstall() {
File(root, "game.bin").writeBytes(byteArrayOf(9, 8, 7))
File(root, "game.bin.part").writeBytes(byteArrayOf(6, 5, 4))
StoreGameSidecar(Store.EPIC, "game", "Game", "game.bin", installVersion = "old").write(root)
}
private fun assertOldInstallIntact() {
assertArrayEquals(byteArrayOf(9, 8, 7), File(root, "game.bin").readBytes())
assertArrayEquals(byteArrayOf(6, 5, 4), File(root, "game.bin.part").readBytes())
assertEquals("old", StoreGameSidecar.read(root)?.installVersion)
assertTrue(StoreGameSidecar.read(root)?.isInstalled == true)
assertTrue(root.walk().none { it.name.endsWith(".stage") })
}
@Test fun networkFailureAfterAcceptedPreflightPreservesInstalledFilesAndRecord() {
oldInstall()
reject.set(true)
assertThrows(EpicDownloadManager.InstallException::class.java) { install() }
assertTrue(requests.get() > 0)
assertOldInstallIntact()
}
@Test fun hashFailureAfterAcceptedPreflightPreservesInstalledFilesAndRecord() {
oldInstall()
manifest.files.first().sha1 = ByteArray(20) { 1 }
assertThrows(EpicDownloadManager.InstallException::class.java) { install() }
assertOldInstallIntact()
}
@Test fun cancellationWhileStagingPreservesInstalledFilesAndRecord() {
oldInstall()
val cancel = AtomicBoolean()
assertNull(install(cancel, EpicDownloadManager.Callback { message, _ ->
if (message.startsWith("Downloading chunks (")) cancel.set(true)
}))
assertOldInstallIntact()
}
@Test fun laterFailureRetainsVerifiedSwapsAndMarksMixedUpdateResumable() {
oldInstall()
manifest.files.last().sha1 = ByteArray(20) { 1 }
assertThrows(EpicDownloadManager.InstallException::class.java) { install() }
assertArrayEquals(bytes.copyOfRange(0, bytes.size / 2), File(root, "game.bin").readBytes())
assertArrayEquals(byteArrayOf(6, 5, 4), File(root, "game.bin.part").readBytes())
val record = StoreGameSidecar.read(root)!!
assertEquals(StoreGameSidecar.STATE_UPDATING, record.state)
assertFalse(record.isInstalled)
assertEquals("", record.installVersion)
StoreInstalls.begin(root, Store.EPIC, "game", "Game", null, null)
assertEquals("game.bin", StoreGameSidecar.read(root)?.exe)
assertEquals(StoreGameSidecar.STATE_UPDATING, StoreGameSidecar.read(root)?.state)
}
@Test fun laterNetworkFailurePreservesTheUnreplacedOldFile() {
oldInstall()
val secondChunk = EpicDownloadManager.ChunkInfo().apply {
guid = intArrayOf(2, 0, 0, 0)
windowSize = bytes.size
fileSize = bytes.size.toLong()
sha1 = sha(bytes)
}
manifest.uniqueChunks.add(secondChunk)
manifest.files.last().parts.single().guid = secondChunk.guid
val callback = object : EpicDownloadManager.Callback {
override fun onProgress(message: String, pct: Int) {}
override fun onStage(stage: String, done: Long, total: Long, items: Int, itemsTotal: Int) {
if (stage == "stream" && items == 1) reject.set(true)
}
}
assertThrows(EpicDownloadManager.InstallException::class.java) { install(callback = callback) }
assertArrayEquals(bytes.copyOfRange(0, bytes.size / 2), File(root, "game.bin").readBytes())
assertArrayEquals(byteArrayOf(6, 5, 4), File(root, "game.bin.part").readBytes())
assertEquals(StoreGameSidecar.STATE_UPDATING, StoreGameSidecar.read(root)?.state)
assertTrue(root.walk().none { it.name.endsWith(".stage") })
}
@Test fun cancellationAfterFirstSwapLeavesAResumableMixedUpdate() {
oldInstall()
val cancel = AtomicBoolean()
val callback = object : EpicDownloadManager.Callback {
override fun onProgress(message: String, pct: Int) {}
override fun onStage(stage: String, done: Long, total: Long, items: Int, itemsTotal: Int) {
if (stage == "stream" && items == 1) cancel.set(true)
}
}
assertNull(install(cancel, callback))
assertArrayEquals(bytes.copyOfRange(0, bytes.size / 2), File(root, "game.bin").readBytes())
assertArrayEquals(byteArrayOf(6, 5, 4), File(root, "game.bin.part").readBytes())
assertEquals(StoreGameSidecar.STATE_UPDATING, StoreGameSidecar.read(root)?.state)
assertNotNull(install())
assertArrayEquals(bytes.copyOfRange(bytes.size / 2, bytes.size), File(root, "game.bin.part").readBytes())
// Registration, not an individual file swap, restores the installed state.
assertFalse(StoreGameSidecar.read(root)!!.isInstalled)
}
@Test fun atomicRenameFailurePreservesDestinationAndCleansStagingWithoutDeleteFallback() {
val destination = File(root, "game.bin").apply { mkdir() }
File(destination, "old-content").writeText("keep")
val error = assertThrows(EpicDownloadManager.InstallException::class.java) { install() }
assertTrue(error.message.orEmpty().contains("atomically replace"))
assertEquals("keep", File(destination, "old-content").readText())
assertTrue(root.walk().none { it.name.endsWith(".stage") })
}
@Test fun safeStagingSpaceBoundaryRejectsWithoutReclaimingAllOldOutputs() {
// The first staged half-file must coexist with the shared chunk and BOTH old outputs.
val old = ByteArray(bytes.size / 2) { 9 }
File(root, "game.bin").writeBytes(old)
File(root, "game.bin.part").writeBytes(old)
val peak = bytes.size + old.size
ShadowStatFs.registerStats(root.path, 1024, peak / 4096 - 1, peak / 4096 - 1)
assertThrows(EpicDownloadManager.InstallException::class.java) { install() }
assertEquals(0, requests.get())
assertArrayEquals(old, File(root, "game.bin").readBytes())
assertArrayEquals(old, File(root, "game.bin.part").readBytes())
ShadowStatFs.registerStats(root.path, 1024, peak / 4096, peak / 4096)
assertNotNull(install())
}
@Test fun zeroSpaceRejectsPendingFilesWhileUnknownSpaceIsNotTreatedAsZero() {
oldInstall()
ShadowStatFs.registerStats(root.path, 1024, 0, 0)
assertThrows(EpicDownloadManager.InstallException::class.java) { install() }
assertOldInstallIntact()
assertEquals(0, requests.get())
assertTrue(com.droiddeck.launcher.stores.StoreInstallRoot.insufficientSpace(1, 0))
assertFalse(com.droiddeck.launcher.stores.StoreInstallRoot.insufficientSpace(1, -1))
}
@Test fun zeroScratchSpaceRejectsBeforeFetchWithoutTouchingInstalledOutputs() {
oldInstall()
val cache = temp.newFolder("full-scratch")
ShadowStatFs.registerStats(cache.path, 1024, 0, 0)
val error = assertThrows(EpicDownloadManager.InstallException::class.java) { install(cache = cache.path) }
assertTrue(error.message.orEmpty().contains("Not enough internal space"))
assertOldInstallIntact()
assertEquals(0, requests.get())
}
@Test fun installsSharedChunksAndPreservesLegitimatePartFilename() {
assertNotNull(install())
assertArrayEquals(bytes.copyOfRange(0, bytes.size / 2), File(root, "game.bin").readBytes())
@@ -122,6 +266,7 @@ class EpicBoundedInstallTest {
assertTrue(manifest.files[0].fileSize() > root.usableSpace)
for (cache in listOf("", temp.newFolder("scratch-rejected").path)) {
if (cache.isNotEmpty()) ShadowStatFs.registerStats(cache, 1_000_000_000, 1_000_000_000, 1_000_000_000)
val error = assertThrows(EpicDownloadManager.InstallException::class.java) { install(cache = cache) }
assertTrue(error.message.orEmpty().contains("Not enough free space"))
assertArrayEquals(oldGame, File(root, "game.bin").readBytes())
@@ -131,11 +276,12 @@ class EpicBoundedInstallTest {
}
@Test fun acceptedUpdateReplacesOutdatedFilesWithVerifiedOutputs() {
File(root, "game.bin").writeBytes(byteArrayOf(9, 8, 7))
File(root, "game.bin.part").writeBytes(byteArrayOf(6, 5, 4))
oldInstall()
assertNotNull(install())
assertArrayEquals(bytes.copyOfRange(0, bytes.size / 2), File(root, "game.bin").readBytes())
assertArrayEquals(bytes.copyOfRange(bytes.size / 2, bytes.size), File(root, "game.bin.part").readBytes())
assertEquals(StoreGameSidecar.STATE_UPDATING, StoreGameSidecar.read(root)?.state)
assertTrue(root.walk().none { it.name.endsWith(".stage") })
}
@Test fun pauseKeepsCompletedFilesAndResumeReusesSharedCache() {
@@ -179,6 +325,7 @@ class EpicBoundedInstallTest {
@Test fun corruptCacheIsRefetchedAndScratchCacheIsRemovedOnSuccess() {
val cache = temp.newFolder("scratch")
ShadowStatFs.registerStats(cache.path, 1_000_000_000, 1_000_000_000, 1_000_000_000)
File(cache, manifest.uniqueChunks[0].guidStr()).writeBytes(ByteArray(bytes.size))
assertNotNull(install(cache = cache.path))
assertEquals(1, requests.get())
@@ -285,4 +432,14 @@ class EpicBoundedInstallTest {
assertEquals(73 * gib, peak)
assertTrue(peak < 96 * gib)
}
@Test fun plannersReclaimEachOldFileOnlyAfterItsOwnVerifiedSwap() {
val chunks = manifest.uniqueChunks.associateBy { it.guidStr() }
val half = bytes.size / 2L
val old = mapOf("game.bin" to half, "game.bin.part" to 100 * half)
assertEquals(bytes.size + half, EpicDownloadManager.estimatePeakAdditionalBytes(manifest.files, chunks, emptyMap(), true, old))
assertEquals(half, EpicDownloadManager.estimateStagedFilePeak(manifest.files, old))
assertEquals(2 * half, EpicDownloadManager.estimateStagedFilePeak(manifest.files, emptyMap()))
assertEquals(bytes.size.toLong(), EpicDownloadManager.estimatePeakAdditionalBytes(manifest.files, chunks, emptyMap(), false, old))
}
}
@@ -3,15 +3,29 @@ package com.droiddeck.launcher.stores.gog
import java.io.File
import java.security.MessageDigest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import org.robolectric.shadows.ShadowStatFs
import com.droiddeck.launcher.stores.StoreInstallRoot
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class GogResumeSpaceTest {
@Test fun remainingWorkRejectsMeasuredZeroButNotUnknownSpace() {
val root = File(RuntimeEnvironment.getApplication().filesDir, "gog-zero").apply { mkdirs() }
ShadowStatFs.registerStats(root.path, 1024, 0, 0)
val files = listOf(GogDownloadManager.DepotFile("missing").apply { totalSize = 4 })
val needed = GogDownloadManager.remainingInstallBytes(files, root)
assertEquals(4L, needed)
assertTrue(StoreInstallRoot.insufficientSpace(needed, StoreInstallRoot.availableBytes(root)))
assertFalse(StoreInstallRoot.insufficientSpace(needed, StoreInstallRoot.SPACE_UNKNOWN))
}
@Test fun remainingSpaceExcludesVerifiedFilesButIncludesCorruptAndPartialFiles() {
val root = File(RuntimeEnvironment.getApplication().filesDir, "gog-resume").apply { mkdirs() }
val data = byteArrayOf(1, 2, 3, 4)
+42 -8
View File
@@ -33,7 +33,12 @@ repair on a card checks and remembers that volume rather than internal storage.
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).
Free-space display probes the nearest existing ancestor without creating the target folder; if
the probe cannot determine space, it reports the unknown value rather than blocking an install.
the probe cannot determine space, it reports **Free space unknown** rather than blocking an install.
`StoreInstallRoot` uses `-1` for unknown and preserves a measured `StatFs` zero as **0 B free**.
Fresh GOG/Amazon preflight and all three Java backend space checks reject known zero when bytes
remain. The read-only probe walks existing ancestors without creating directories; a failed
`StatFs` probe can use a positive `File.usableSpace` fallback, but that API's ambiguous zero is
unknown, not a measurement of a full disk.
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. Failures are kept in the Downloads UI, notification and logs rather than
@@ -48,6 +53,18 @@ 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 earlier batch design intentionally prioritizes parallel-download throughput. Native
`epic/assemble.rs::build_assemble_plan` enables `scratch_cache` only for a nonempty cache path.
`run_assemble` decrements per-file chunk references and removes chunks at their last use **only
when that flag is set**; the default beside-game cache is removed at the end. The native tests
`default_cache_assembles_like_the_kotlin_loop_and_removes_the_cache_at_the_end` and
the scratch-cache assembly tests cover that distinction. Extending last-use reclamation to
internal native assembly could keep parallel batch throughput and reduce the overlapping
cache/output peak, but batch fetching still fills
the cache before assembly. That is not equivalent to the bounded sequential writer's fixed
retained-cache ceiling. This change keeps the bounded path and its explicit throughput/refetch
tradeoff; it does not redesign or benchmark native download throughput.
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.
@@ -55,14 +72,20 @@ Streaming stays in the Download stage until all files are written and verified,
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. It accounts for space
reclaimable from outdated outputs without deleting them until both game and cache volume
checks pass. An insufficient-space rejection preserves existing game files. Completed files and
and evictions, rather than adding the entire cache to the entire game. Each file is written to a
unique sibling staging file on its output volume (never the literal `<filename>.part`, which can
be a legitimate manifest file), size/hash verified, synced and atomically renamed over its output.
The old file remains until that swap succeeds. The planner credits its bytes only after that
file's complete staging write, not by subtracting all old outputs up front. Both volume checks
must accept this safe staging peak; insufficient space is rejected explicitly, with no destructive
delete-first fallback. An unsupported/failed atomic rename fails the install and retains the old
destination. Completed files and
verified cached chunks are accounted for on resume. On a card, game and cache requirements
are checked separately. File SHA-1 hashes are checked where supplied by the manifest; failed
or cancelled output files are removed, and an interrupted process is repaired by the next
delta pass. A successful run removes the cache. A resume within a large incomplete file
restarts that file, not its individual parts.
or cancelled staging files are removed without deleting old outputs. A successful run removes the
cache. A resume within a large incomplete file restarts that file, not its individual parts.
Abrupt process termination can leave a unique staging file; unlike normal failure/cancellation,
its cleanup is not guaranteed, and the next space probe includes its occupied bytes.
## The sidecar
@@ -89,7 +112,18 @@ store root whose sidecar says `installing`, or that has no sidecar at all, is an
install: `AddedGames.scan` leaves it out (it is neither a game nor a Custom folder), and the Stores
card and page offer **Resume install**, which reuses that folder - and an Epic `.chunks` beside it -
instead of starting a fresh one (`StoreInstallRoot.existingFolder`). A finished install being
repaired keeps its finished sidecar. The launcher and the finished sidecar are written before the
repaired keeps its finished sidecar until Epic reaches the first verified file swap. Immediately
before that swap Epic atomically changes the record to `"state": "updating"`, preserves launch
choices and identifiers, and clears `installVersion`. Network/hash failures or cancellation before
that point leave the old installed record and files intact. A later failure can leave a mixture of
verified new files and untouched old files: replacement is atomic **per file**, not a transactional
whole-game update or rollback. The updating folder is excluded from installed scans and offered
for resume. Cancellation with deletion clears caches but retains that folder and its verified
swaps; retry preserves the updating record's metadata. If marking or swapping fails, no destructive
fallback is attempted (the conservative updating state can remain even when a swap fails).
Completion writes the installed record with the new version only after all selected files succeed.
An already registered live Steam shortcut is not revoked at each file swap; do not launch an
in-progress update. The launcher and the finished sidecar are written before the
art and the Steam registration; a failure writing them fails the download with its message.
`StoreGameSidecar.parse` refuses anything whose `exe` or `launcher` would point outside the folder.