mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Preserve Epic files during replacement and distinguish full storage
This commit is contained in:
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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user