mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Simplify storage probes, cache policy, and failure feedback
This commit is contained in:
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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user