Simplify storage probes, cache policy, and failure feedback

This commit is contained in:
Juan Marti Mercado committed 2026-10-13 19:15:36 -04:00
1 parent 8ff1f5dd20
commit 87e6556993
8 files changed
+88 -51

No files matched your search

@@ -36,12 +36,28 @@ object StoreInstallRoot {
val errorMessage: String? = null
)
fun availableBytes(file: File): Long = try {
val dir = if (file.isDirectory) file else (file.parentFile ?: file)
if (!dir.exists()) dir.mkdirs()
StatFs(if (dir.exists()) dir.path else file.path).availableBytes
fun availableBytes(file: File): Long {
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
} catch (e: Exception) {
StoresState.logLine("storage: StatFs failed for store install target (${e.javaClass.simpleName})")
usableSpaceOrUnknown(file)
}
}
private fun usableSpaceOrUnknown(file: File): Long = try {
file.usableSpace
} catch (e: Exception) {
try { file.usableSpace } catch (ignored: Exception) { 0L }
StoresState.logLine("storage: usable-space fallback failed for store install target (${e.javaClass.simpleName})")
0L
}
/**
@@ -208,9 +208,6 @@ object DownloadQueue {
publishLocked()
}
StoresState.logLine("${entry.store.label}: \"${entry.name}\" failed: $error")
main.post {
android.widget.Toast.makeText(app, error, android.widget.Toast.LENGTH_LONG).show()
}
StoreDownloadService.ended(app, entry.copy(state = DownloadState.FAILED, error = error), installed = false, gamesKey = null)
}
@@ -412,10 +409,6 @@ object DownloadQueue {
}
DownloadState.FAILED -> {
StoresState.logLine("${item.entry.store.label}: \"${item.entry.name}\" failed: $error")
val failMsg = error ?: context.getString(com.droiddeck.launcher.R.string.stores_dl_failed)
main.post {
android.widget.Toast.makeText(context.applicationContext, failMsg, android.widget.Toast.LENGTH_LONG).show()
}
StoreDownloadService.ended(context, item.entry, installed = false, gamesKey = null)
}
DownloadState.CANCELLED -> StoresState.notifyLibraryChanged(context)
@@ -280,15 +280,11 @@ public final class EpicDownloadManager {
completedBytes += part.size & 0xFFFFFFFFL;
long elapsedMs = Math.max(1, (System.nanoTime() - transferStarted) / 1_000_000);
cb.onBytes(completedBytes, fTotalBytes, completedBytes * 1000 / elapsedMs);
Integer refs = remainingRefs.get(guid);
if (refs != null) {
if (refs <= 1) {
remainingRefs.remove(guid);
if (cachedChunk.exists() && !cachedChunk.delete() && cachedChunk.exists()) {
throw new InstallException("Could not release consumed Epic chunk " + guid);
}
cachedChunkBytes.remove(guid);
} else remainingRefs.put(guid, refs - 1);
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);
}
@@ -348,19 +344,42 @@ public final class EpicDownloadManager {
/** Evict oldest reusable chunks until retained cache data is under the fixed disk ceiling. */
private static void trimCacheToBound(File cacheDir, Map<String, Long> cacheSizes) throws InstallException {
for (String guid : evictOldestReusableChunks(cacheSizes).keySet()) {
File chunk = new File(cacheDir, guid);
if (chunk.exists() && !chunk.delete() && chunk.exists()) {
throw new InstallException("Could not release Epic cache chunk " + guid);
}
}
}
/**
* The writer and space planner use the same oldest-first retained-cache policy. Entries leave
* this map as soon as their last reference is consumed; an oversized incoming chunk is
* therefore allowed to coexist with the retained cache until its current part is written.
*/
private static Map<String, Long> evictOldestReusableChunks(Map<String, Long> cacheSizes) {
long retained = 0;
for (long bytes : cacheSizes.values()) retained += bytes;
Map<String, Long> evicted = new LinkedHashMap<>();
java.util.Iterator<Map.Entry<String, Long>> iterator = cacheSizes.entrySet().iterator();
while (retained > MAX_RETAINED_CHUNK_CACHE_BYTES && iterator.hasNext()) {
Map.Entry<String, Long> entry = iterator.next();
File chunk = new File(cacheDir, entry.getKey());
long bytes = entry.getValue();
if (chunk.exists() && !chunk.delete() && chunk.exists()) {
throw new InstallException("Could not release Epic cache chunk " + entry.getKey());
}
retained -= bytes;
retained -= entry.getValue();
evicted.put(entry.getKey(), entry.getValue());
iterator.remove();
}
return evicted;
}
private static boolean consumeChunkReference(Map<String, Integer> refs, String guid) {
Integer count = refs.get(guid);
if (count == null) return false;
if (count <= 1) {
refs.remove(guid);
return true;
}
refs.put(guid, count - 1);
return false;
}
static boolean isValidCachedChunk(ChunkInfo chunk, File file) {
@@ -410,8 +429,6 @@ public final class EpicDownloadManager {
Map<String, Integer> refs = chunkReferences(files);
Map<String, Long> residentChunks = new LinkedHashMap<>(cachedChunkBytes);
long residentDelta = 0, peak = 0;
long cacheResident = 0;
for (long bytes : residentChunks.values()) cacheResident += bytes;
for (FileInfo file : files) {
for (ChunkPart part : file.parts) {
String guid = part.guidStr();
@@ -421,7 +438,6 @@ public final class EpicDownloadManager {
long chunkBytes = knownBytes != null ? knownBytes : chunkWindowSize(chunk);
if (knownBytes == null) {
residentChunks.put(guid, chunkBytes);
cacheResident += chunkBytes;
residentDelta += chunkBytes;
peak = Math.max(peak, residentDelta);
}
@@ -429,22 +445,11 @@ public final class EpicDownloadManager {
residentDelta += part.size & 0xFFFFFFFFL;
peak = Math.max(peak, residentDelta);
}
int count = refs.get(guid);
if (count <= 1) {
refs.remove(guid);
if (consumeChunkReference(refs, guid)) {
Long removed = residentChunks.remove(guid);
if (removed != null) { cacheResident -= removed; residentDelta -= removed; }
} else refs.put(guid, count - 1);
java.util.Iterator<Map.Entry<String, Long>> iterator = residentChunks.entrySet().iterator();
while (cacheResident > MAX_RETAINED_CHUNK_CACHE_BYTES && iterator.hasNext()) {
Map.Entry<String, Long> entry = iterator.next();
if (refs.containsKey(entry.getKey())) {
long removed = entry.getValue();
cacheResident -= removed;
residentDelta -= removed;
iterator.remove();
}
if (removed != null) residentDelta -= removed;
}
for (long bytes : evictOldestReusableChunks(residentChunks).values()) residentDelta -= bytes;
}
}
return Math.max(0, peak);
@@ -73,11 +73,12 @@ internal fun InstallWhereDialog(item: CatalogItem, onDismiss: () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
targets.forEachIndexed { i, t ->
val needed = com.droiddeck.launcher.stores.StoreInstallRoot.estimatedBytesNeeded(item)
val isLow = needed > 0 && t.freeBytes in 1 until needed
val freeBytes = t.freeBytes
val isLow = needed > 0 && freeBytes in 1 until needed
val freeText = if (isLow) {
stringResource(R.string.stores_storage_low, formatBytes(t.freeBytes), formatBytes(needed))
stringResource(R.string.stores_storage_low, formatBytes(freeBytes), formatBytes(needed))
} else {
stringResource(R.string.stores_target_free, formatBytes(t.freeBytes))
stringResource(R.string.stores_target_free, formatBytes(freeBytes))
}
TargetRow(
label = if (t.removable) t.label else stringResource(R.string.stores_target_internal),
@@ -512,11 +512,12 @@ private fun InstallWhere(item: CatalogItem) {
StepList {
targets.forEachIndexed { i, t ->
val needed = com.droiddeck.launcher.stores.StoreInstallRoot.estimatedBytesNeeded(item)
val isLow = needed > 0 && t.freeBytes in 1 until needed
val freeBytes = t.freeBytes
val isLow = needed > 0 && freeBytes in 1 until needed
val trailingText = if (isLow) {
stringResource(R.string.stores_storage_low, formatBytes(t.freeBytes), formatBytes(needed))
stringResource(R.string.stores_storage_low, formatBytes(freeBytes), formatBytes(needed))
} else {
stringResource(R.string.stores_target_free, formatBytes(t.freeBytes))
stringResource(R.string.stores_target_free, formatBytes(freeBytes))
}
StepChoice(
label = if (t.removable) t.label else internalLabel,
@@ -75,6 +75,15 @@ class StoreInstallRootTest {
assertTrue(check.enough)
}
@Test fun availableBytesUsesNearestExistingAncestorWithoutCreatingDirectories() {
val existing = File(app.filesDir, "storage-probe").apply { mkdirs() }
val missing = File(existing, "not-created/another-level/game")
ShadowStatFs.registerStats(existing.path, 1024, 100, 100)
assertTrue(StoreInstallRoot.availableBytes(missing) > 0L)
assertFalse(File(existing, "not-created").exists())
}
@Test fun epicDefersFullSizeChecksToItsManifestDeltaForResume() {
val item = CatalogItem(Store.EPIC, "large", "Large", imageUrl = null, owned = true, sizeBytes = Long.MAX_VALUE)
val check = StoreInstallRoot.checkStorage(app, item, StoreInstallRoot.internalRoot(app))
@@ -237,6 +237,14 @@ class EpicBoundedInstallTest {
sha1 = sha(bytes)
}
manifest.files = (0..2).map { gameFile("part-$it.bin", it * 1024, 1024) }
val chunk = manifest.uniqueChunks.single()
val estimatedPeak = EpicDownloadManager.estimatePeakAdditionalBytes(
manifest.files,
mapOf(chunk.guidStr() to chunk),
emptyMap(),
true,
)
assertEquals(EpicDownloadManager.MAX_RETAINED_CHUNK_CACHE_BYTES + 4 * 1024L, estimatedPeak)
val samples = mutableListOf<Long>()
val stages = mutableListOf<String>()
val callback = object : EpicDownloadManager.Callback {
+5 -1
View File
@@ -32,9 +32,13 @@ Preflight resolves an existing game's folder before selecting its target root, s
repair on a card checks and remembers that volume rather than internal storage. Fresh GOG and
Amazon installs can reject insufficient catalog-size space immediately; existing installs defer
to manifest checks of the files still needing work (GOG MD5, Amazon SHA-256 where supplied).
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.
Preflight failures remain failed Downloads rows with Clear and Retry. Retry reruns preflight
outside the worker lifecycle and then enqueues the real backend; it never occupies a queue slot
with a placeholder job. Active queue persistence and ordinary backend pause/retry are unchanged.
with a placeholder job. Failures are kept in the Downloads UI, notification and logs rather than
duplicated as transient failure toasts. Active queue persistence and ordinary backend pause/retry
are unchanged.
Epic installs use the same bounded Java writer whether the native engine is available or not.
It fetches one chunk at a time, verifies it, and writes the required parts into the current