From cb2f7c0109f8847a8a137ab4e9e78461460141c3 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Thu, 8 Oct 2026 21:01:17 -0400 Subject: [PATCH] Stores: stages named for what they do, both sizes, Cancel deletes Epic's check of the files already there belongs to Manifest (Checking n/N), not a Verify at 100% before anything is fetched. Install measures its own write rate and ETA from the assembler's bytes and shows files n/N. A row shows the download as fetched (compressed) against its total, and the size on disk beside the location; Epic keeps the disk size for the card and page. Cancel asks twice and removes everything the install wrote - folder, sidecar, chunk caches, scratch - unless the folder is a finished install being repaired, which keeps itself; the row reads Cancelled for three seconds, then goes. Pause keeps everything. A failed row reads Failed with what is kept and offers Resume and Clear, Clear deleting as Cancel does; the game page offers Resume install and Clear for an unfinished install. Library sync is a thin bar and its count. --- .../launcher/stores/StoreInstalls.kt | 17 +++++ .../droiddeck/launcher/stores/StoresState.kt | 11 +++ .../launcher/stores/amazon/AmazonBackend.kt | 2 +- .../stores/download/DownloadModels.kt | 2 + .../launcher/stores/download/DownloadQueue.kt | 71 ++++++++++++++----- .../launcher/stores/epic/EpicBackend.kt | 12 ++-- .../stores/epic/EpicDownloadManager.java | 14 ++-- .../launcher/stores/gog/GogBackend.kt | 6 +- .../stores/gog/GogDownloadManager.java | 2 +- .../com/droiddeck/launcher/ui/StoresDetail.kt | 9 ++- .../droiddeck/launcher/ui/StoresDownloads.kt | 27 +++---- .../com/droiddeck/launcher/ui/StoresPage.kt | 39 +++++++++- app/src/main/res/values/strings.xml | 5 +- docs/progress-log.md | 11 +++ 14 files changed, 171 insertions(+), 57 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt index 40e312c7c..dd72d5af3 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt @@ -77,6 +77,23 @@ object StoreInstalls { StoresState.logLine("uninstalled \"${game.sidecar.title}\"") } + /** + * 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. + */ + fun discard(context: Context, folder: File, caches: List) { + val app = context.applicationContext + Thread({ + val keep = StoreGameSidecar.read(folder)?.isInstalled == true + caches.forEach { deleteTree(it) } + if (!keep) deleteTree(folder) + StoresState.logLine(if (keep) "removed the partial update of ${folder.name}" else "removed ${folder.name}") + StoresState.notifyLibraryChanged(app) + }, "store-cancel-clean").start() + } + fun deleteTree(dir: File) { dir.listFiles()?.forEach { if (it.isDirectory) deleteTree(it) else it.delete() } dir.delete() diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt index 28cc05024..4a3cf7f9a 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt @@ -98,6 +98,17 @@ object StoresState { it.store == item.store && (it.id == item.id || (it.id == null && it.folder.name == StoreInstallRoot.folderName(item.title, item.id))) } + /** Deletes an unfinished install's folder and caches, as Cancel would; drops its row if there is one. */ + fun clearUnfinished(context: Context, item: CatalogItem) { + val app = context.applicationContext + val key = "${item.store.id}:${item.id}" + if (com.droiddeck.launcher.stores.download.DownloadQueue.entry(key) != null) { com.droiddeck.launcher.stores.download.DownloadQueue.clear(app, key); return } + val folder = unfinished.firstOrNull { + it.store == item.store && (it.id == item.id || (it.id == null && it.folder.name == StoreInstallRoot.folderName(item.title, item.id))) + }?.folder ?: return + StoreInstalls.discard(app, folder, listOf(java.io.File(folder, ".chunks"), java.io.File(folder, ".gog_chunks"), StoreInstallRoot.scratchDir(app, item.store, item.id))) + } + fun installedGame(store: Store, id: String): InstalledStoreGame? = installed.firstOrNull { it.sidecar.store == store && it.sidecar.id == id } /** The user's library and the shelves for [store], from the cache first and the network when stale. */ diff --git a/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonBackend.kt index d4c404060..a4e710f56 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonBackend.kt @@ -127,7 +127,7 @@ object AmazonBackend : StoreBackend { override fun cancel(deleteFiles: Boolean) { cancelled.set(true) - if (deleteFiles) Thread({ StoreInstalls.deleteTree(folder) }, "amazon-cancel-clean").start() + if (deleteFiles) StoreInstalls.discard(app, folder, emptyList()) } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadModels.kt b/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadModels.kt index be88366a7..9813f169d 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadModels.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadModels.kt @@ -52,6 +52,8 @@ data class DownloadEntry( val stageTotal: Long = 0L, val stageItems: Int = 0, val stageItemsTotal: Int = 0, + /** What the game takes on disk, when the store has said (the download itself is [bytesTotal], often compressed). */ + val diskBytes: Long = 0L, /** Stages already passed, one bit per [DownloadStage] ordinal; stages can come back (Epic checks files before it fetches). */ val stagesPassed: Int = 0, ) { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt b/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt index d54b478cc..01d84e53c 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt @@ -35,10 +35,13 @@ object DownloadQueue { val isCancelled: Boolean get() = cancelled.get() /** Enters [stage] (a repeat of the current one only updates the line); a new stage starts its own count from zero. */ fun stage(stage: DownloadStage, detail: String = "") = enter(key, stage, stripSpeed(detail)) - /** The active stage's own count outside Download: an amount (bytes or items) and, when it counts files, the files. */ - fun stageProgress(done: Long, total: Long, items: Int = 0, itemsTotal: Int = 0) = update(key) { - it.copy(stageDone = done, stageTotal = total, stageItems = items, stageItemsTotal = itemsTotal) - } + /** + * The active stage's own count outside Download: an amount and, when it counts files, the + * files. [bytes] = the amount is bytes written, so the stage gets its own rate and ETA. + */ + fun stageProgress(done: Long, total: Long, items: Int = 0, itemsTotal: Int = 0, bytes: Boolean = false) = stageCount(key, done, total, items, itemsTotal, bytes) + /** The game's size on disk, once the store has said. */ + fun diskSize(bytes: Long) = update(key) { if (bytes > 0) it.copy(diskBytes = bytes) else it } /** * Bytes so far (negative = unchanged), the total (<= 0 = unchanged), a line. [speedBps] is the * engine's own figure: a burst rate at the network side, which jumps while the write side @@ -79,6 +82,34 @@ object DownloadQueue { /** How far back the shown speed looks: a few seconds, so the figure settles instead of following each file. */ private const val SPEED_WINDOW_MS = 3000.0 + private fun stageCount(key: String, done: Long, total: Long, files: Int, filesTotal: Int, bytes: Boolean) { + synchronized(lock) { + val item = items[key] ?: return + val e = item.entry + var speed = e.speedBps + if (bytes) speed = sample(item, done) + val eta = if (bytes && speed > 0 && total > done) (total - done) / speed else -1L + item.entry = e.copy(stageDone = done, stageTotal = total, stageItems = files, stageItemsTotal = filesTotal, speedBps = if (bytes) speed else 0, etaSeconds = eta) + publishLocked() + } + } + + /** One sample of the rate behind [done] (bytes so far in this stage), smoothed over [SPEED_WINDOW_MS]. Caller holds [lock]. */ + private fun sample(item: Item, done: Long): Long { + val now = System.currentTimeMillis() + if (item.speedAt == 0L) { item.speedAt = now; item.speedBytes = done; return item.speedEwma.toLong() } + val dt = now - item.speedAt + // Sampled no faster than every quarter second, as an exponential average over + // SPEED_WINDOW_MS: one big file landing moves it, it does not define it. + if (dt >= 250) { + val inst = (done - item.speedBytes).coerceAtLeast(0) * 1000.0 / dt + val alpha = 1.0 - Math.exp(-dt / SPEED_WINDOW_MS) + item.speedEwma = if (item.speedEwma <= 0.0) inst else item.speedEwma + (inst - item.speedEwma) * alpha + item.speedAt = now; item.speedBytes = done + } + return item.speedEwma.toLong() + } + private fun measure(key: String, bytesDone: Long, bytesTotal: Long, detail: String?) { synchronized(lock) { val item = items[key] ?: return @@ -86,21 +117,7 @@ object DownloadQueue { val e = item.entry val done = if (bytesDone >= 0) bytesDone else e.bytesDone val total = if (bytesTotal > 0) bytesTotal else e.bytesTotal - val now = System.currentTimeMillis() - if (bytesDone >= 0) { - if (item.speedAt == 0L) { item.speedAt = now; item.speedBytes = done } - else { - val dt = now - item.speedAt - // Sampled no faster than every quarter second, as an exponential average over - // SPEED_WINDOW_MS: one big file landing moves it, it does not define it. - if (dt >= 250) { - val inst = (done - item.speedBytes).coerceAtLeast(0) * 1000.0 / dt - val alpha = 1.0 - Math.exp(-dt / SPEED_WINDOW_MS) - item.speedEwma = if (item.speedEwma <= 0.0) inst else item.speedEwma + (inst - item.speedEwma) * alpha - item.speedAt = now; item.speedBytes = done - } - } - } + if (bytesDone >= 0) sample(item, done) val speed = item.speedEwma.toLong() val eta = if (speed > 0 && total > done) (total - done) / speed else -1L item.entry = e.copy(state = DownloadState.RUNNING, stage = DownloadStage.DOWNLOAD, bytesDone = done, bytesTotal = total, detail = detail ?: e.detail, speedBps = speed, etaSeconds = eta) @@ -198,6 +215,22 @@ object DownloadQueue { StoresState.logLine("cancelled \"${item.entry.name}\"") StoreDownloadService.finish(app, key) advance(app) + // The row says Cancelled for a moment, then goes; the game reads Install again. + Thread({ Thread.sleep(3000); synchronized(lock) { if (items[key]?.entry?.state == DownloadState.CANCELLED) { items.remove(key); publishLocked() } } }, "store-dl-cancelled").start() + } + + /** + * Clear on a failed or paused row: deletes what it kept exactly as Cancel does (the job's own + * cleanup), then drops the row. An installed or cancelled row is only dropped. + */ + fun clear(context: Context, key: String) { + val item = synchronized(lock) { items[key] } ?: return + if (item.entry.state == DownloadState.RUNNING || item.entry.state == DownloadState.QUEUED) return + if (item.entry.state == DownloadState.FAILED || item.entry.state == DownloadState.PAUSED) { + Thread({ runCatching { item.factory().cancel(deleteFiles = true) } }, "store-dl-clear").start() + } + synchronized(lock) { items.remove(key); publishLocked() } + StoreDownloadService.finish(context.applicationContext, key) } /** Drops a finished, failed or cancelled row from the list. */ diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt index fbd9c4cb7..622268476 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt @@ -121,15 +121,19 @@ object EpicBackend : StoreBackend, EpicLaunchSupport { when { message.startsWith("Writing") || message.startsWith("Complete") -> handle.stage(DownloadStage.INSTALL, message) message.startsWith("Downloading chunks") -> { if (!downloading) { downloading = true; handle.stage(DownloadStage.DOWNLOAD, message) } else handle.progress(-1, -1, message) } - message.startsWith("Verifying") -> handle.stage(DownloadStage.VERIFY, message) + message.startsWith("Checking") -> handle.stage(DownloadStage.MANIFEST, message) else -> handle.stage(DownloadStage.MANIFEST, message) } } override fun onBytes(done: Long, total: Long, speedBps: Long) { handle.progress(done, total, null, speedBps) } override fun onLog(line: String) { handle.log(line) } + override fun onSizes(downloadBytes: Long, diskBytes: Long) { + handle.diskSize(diskBytes) + EpicLibrary.rememberSize(app, item.id, diskBytes) + } override fun onStage(stage: String, done: Long, total: Long, items: Int, itemsTotal: Int) { - handle.stage(if (stage == "verify") DownloadStage.VERIFY else DownloadStage.INSTALL) - handle.stageProgress(done, total, items, itemsTotal) + handle.stage(when (stage) { "check" -> DownloadStage.MANIFEST; "verify" -> DownloadStage.VERIFY; else -> DownloadStage.INSTALL }) + handle.stageProgress(done, total, items, itemsTotal, bytes = stage == "install") } }) ?: return null if (cancelled.get()) return null @@ -151,7 +155,7 @@ object EpicBackend : StoreBackend, EpicLaunchSupport { override fun cancel(deleteFiles: Boolean) { cancelled.set(true) - if (deleteFiles) Thread({ StoreInstalls.deleteTree(folder); scratch?.let { StoreInstalls.deleteTree(it) } }, "epic-cancel-clean").start() + if (deleteFiles) StoreInstalls.discard(app, folder, listOfNotNull(File(folder, ".chunks"), scratch, StoreInstallRoot.scratchDir(app, Store.EPIC, item.id))) } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicDownloadManager.java b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicDownloadManager.java index 76fc15d00..552ad648c 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicDownloadManager.java +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicDownloadManager.java @@ -57,11 +57,14 @@ public final class EpicDownloadManager { default void onBytes(long done, long total, long speedBps) {} default void onLog(String line) {} /** - * The active stage's own count: {@code stage} is "verify" or "install", {@code done} of + * The active stage's own count: {@code stage} is "check" (the files already there, before + * anything is fetched), "verify" or "install" (bytes written), {@code done} of * {@code total} an amount (bytes, or items when there are no bytes), {@code items} of * {@code itemsTotal} the files, when it counts files. */ default void onStage(String stage, long done, long total, int items, int itemsTotal) {} + /** What this run fetches (compressed) and what the game takes on disk. */ + default void onSizes(long downloadBytes, long diskBytes) {} } private EpicDownloadManager() {} @@ -157,19 +160,19 @@ public final class EpicDownloadManager { final long planned = installBytes; // Free space is checked after the delta pass, against what is still missing (below). - cb.onProgress("Verifying existing files…", 0); + cb.onProgress("Checking files…", 0); List pending = new ArrayList<>(selected.size()); int checked = 0, good = 0; long checkedBytes = 0; - cb.onStage("verify", 0, installBytes, 0, selected.size()); + cb.onStage("check", 0, installBytes, 0, selected.size()); for (FileInfo f : selected) { if (cancel.get()) return null; File out = new File(installDir, f.filename.replace("\\", "/")); if (fileExistsWithCorrectHash(out, f.fileSize(), f.sha1)) good++; else pending.add(f); checked++; checkedBytes += f.fileSize(); - if ((checked & 15) == 0 || checked == selected.size()) cb.onStage("verify", checkedBytes, installBytes, checked, selected.size()); - if ((checked & 63) == 0) cb.onProgress("Verifying existing files… (" + checked + "/" + selected.size() + ")", 0); + if ((checked & 15) == 0 || checked == selected.size()) cb.onStage("check", checkedBytes, installBytes, checked, selected.size()); + if ((checked & 63) == 0) cb.onProgress("Checking files… (" + checked + "/" + selected.size() + ")", 0); } cb.onLog("epic: delta " + good + " up to date, " + pending.size() + " to download"); if (pending.isEmpty()) { @@ -204,6 +207,7 @@ public final class EpicDownloadManager { final AtomicLong lastSpeedMs = new AtomicLong(System.currentTimeMillis()); final AtomicLong lastSpeedBytes = new AtomicLong(0); final AtomicLong speedBps = new AtomicLong(0); + cb.onSizes(fTotalBytes, planned); cb.onBytes(0, fTotalBytes, 0); cb.onLog("epic: " + fmt(fTotalBytes) + " in " + totalChunks + " chunks"); diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt index cd9852460..ec97b8ac5 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt @@ -96,8 +96,8 @@ object GogBackend : StoreBackend { override fun onBytes(done: Long, total: Long, speedBps: Long) { handle.progress(done, total, null, speedBps) } override fun onLog(line: String) { handle.log(line) } override fun onStage(stage: String, done: Long, total: Long, items: Int, itemsTotal: Int) { - handle.stage(if (stage == "verify") DownloadStage.VERIFY else DownloadStage.INSTALL) - handle.stageProgress(done, total, items, itemsTotal) + handle.stage(when (stage) { "check" -> DownloadStage.MANIFEST; "verify" -> DownloadStage.VERIFY; else -> DownloadStage.INSTALL }) + handle.stageProgress(done, total, items, itemsTotal, bytes = stage == "install") } }, cancelled) ?: return null if (cancelled.get()) return null @@ -114,7 +114,7 @@ object GogBackend : StoreBackend { override fun cancel(deleteFiles: Boolean) { cancelled.set(true) - if (deleteFiles) Thread({ StoreInstalls.deleteTree(folder) }, "gog-cancel-clean").start() + if (deleteFiles) StoreInstalls.discard(app, folder, listOf(File(folder, ".gog_chunks"))) } } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogDownloadManager.java b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogDownloadManager.java index 4d45c92c1..1fb1cd6bd 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogDownloadManager.java +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogDownloadManager.java @@ -903,7 +903,7 @@ public final class GogDownloadManager { for (File f : files) { deleteDir(f); done++; - if ((done & 31) == 0 || done == total) cb.onStage("install", done, total, done, total); + if ((done & 31) == 0 || done == total) cb.onStage("cleanup", done, total, done, total); } dir.delete(); } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt index de3799600..3748a58cc 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt @@ -88,12 +88,15 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro } } download != null -> { - ActionChip(downloadLabel(download.state, download.stage, download.percent), ok = false) + ActionChip(downloadLabel(download), ok = false) if (download.state != DownloadState.PAUSED) SecondaryButton(stringResource(R.string.stores_dl_pause)) { com.droiddeck.launcher.stores.download.DownloadQueue.pause(download.key) } else PrimaryButton(stringResource(R.string.stores_dl_resume), main = true) { com.droiddeck.launcher.stores.download.DownloadQueue.resume(ctx, download.key) } - SecondaryButton(stringResource(R.string.stores_dl_cancel)) { com.droiddeck.launcher.stores.download.DownloadQueue.cancel(ctx, download.key) } + ConfirmButton(stringResource(R.string.stores_dl_cancel), stringResource(R.string.stores_dl_cancel_confirm)) { com.droiddeck.launcher.stores.download.DownloadQueue.cancel(ctx, download.key) } + } + item?.owned == true && StoresState.isUnfinished(item) -> { + PrimaryButton(stringResource(R.string.stores_resume_install), main = true) { StoresState.requestInstall(ctx, item) } + ConfirmButton(stringResource(R.string.stores_dl_clear), stringResource(R.string.stores_dl_cancel_confirm), compact = true) { StoresState.clearUnfinished(ctx, item) } } - item?.owned == true && StoresState.isUnfinished(item) -> PrimaryButton(stringResource(R.string.stores_resume_install), main = true) { StoresState.requestInstall(ctx, item) } item?.owned == true -> PrimaryButton( if (item.sizeBytes > 0) stringResource(R.string.stores_install_size, formatBytes(item.sizeBytes)) else stringResource(R.string.stores_install), main = true, ) { StoresState.requestInstall(ctx, item) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt index 01cfd37b5..520b15605 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt @@ -98,18 +98,20 @@ private fun DownloadCard(d: DownloadEntry, s: FrontEndState, a: FrontEndActions) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Text(d.name, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = ink, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) SourceChip(d.store.id, small = true) - if (d.location.isNotBlank()) Text(d.location, fontSize = 11.sp, color = dim, maxLines = 1, overflow = TextOverflow.Ellipsis) + val where = listOfNotNull(d.location.takeIf { it.isNotBlank() }, d.diskBytes.takeIf { it > 0 }?.let { formatBytes(it) }).joinToString(" · ") + if (where.isNotEmpty()) Text(where, fontSize = 11.sp, color = dim, maxLines = 1, overflow = TextOverflow.Ellipsis) } StageBar(d) Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { val meta = when (d.state) { DownloadState.INSTALLED -> stringResource(R.string.stores_installed_chip) DownloadState.CANCELLED -> stringResource(R.string.stores_dl_cancelled) - DownloadState.FAILED -> d.error?.let { stringResource(R.string.stores_dl_failed_reason, it) } ?: stringResource(R.string.stores_dl_failed) + // The reason is in the engine log; the row only says what is kept for Resume. + DownloadState.FAILED -> if (d.bytesDone > 0) stringResource(R.string.stores_dl_failed_kept, formatBytes(d.bytesDone)) else stringResource(R.string.stores_dl_failed) DownloadState.PAUSED -> stringResource(R.string.stores_dl_paused) DownloadState.QUEUED -> if (d.queuePosition > 0) stringResource(R.string.stores_dl_queued_at, d.queuePosition) else stringResource(R.string.stores_dl_queued) DownloadState.RUNNING -> { - val head = if (d.stageFraction >= 0f) stringResource(R.string.stores_dl_active_percent, activeStageLabel(d.stage), d.percent) else activeStageLabel(d.stage) + val head = downloadLabel(d) when { d.stage == DownloadStage.DOWNLOAD && d.bytesTotal > 0 -> stringResource(R.string.stores_dl_with, head, stringResource(R.string.stores_dl_amount, formatBytes(d.bytesDone), formatBytes(d.bytesTotal))) d.stageItemsTotal > 0 -> stringResource(R.string.stores_dl_with, head, stringResource(R.string.stores_dl_items, d.stageItems, d.stageItemsTotal)) @@ -118,7 +120,7 @@ private fun DownloadCard(d: DownloadEntry, s: FrontEndState, a: FrontEndActions) } } Text(meta, fontSize = 12.sp, color = dim, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) - if (d.state == DownloadState.RUNNING && d.stage == DownloadStage.DOWNLOAD && d.speedBps > 0) { + if (d.state == DownloadState.RUNNING && (d.stage == DownloadStage.DOWNLOAD || d.stage == DownloadStage.INSTALL) && d.speedBps > 0) { Text(formatSpeed(d.speedBps), fontSize = 12.sp, color = dim, maxLines = 1) if (d.etaSeconds >= 0) Text(eta(d.etaSeconds), fontSize = 12.sp, color = dim, maxLines = 1) } @@ -129,15 +131,15 @@ private fun DownloadCard(d: DownloadEntry, s: FrontEndState, a: FrontEndActions) when (d.state) { DownloadState.RUNNING, DownloadState.QUEUED -> { SecondaryButton(stringResource(R.string.stores_dl_pause), compact = true) { DownloadQueue.pause(d.key) } - SecondaryButton(stringResource(R.string.stores_dl_cancel), compact = true) { DownloadQueue.cancel(ctx, d.key) } + ConfirmButton(stringResource(R.string.stores_dl_cancel), stringResource(R.string.stores_dl_cancel_confirm), compact = true) { DownloadQueue.cancel(ctx, d.key) } } DownloadState.PAUSED -> { PrimaryButton(stringResource(R.string.stores_dl_resume), compact = true) { DownloadQueue.resume(ctx, d.key) } - SecondaryButton(stringResource(R.string.stores_dl_cancel), compact = true) { DownloadQueue.cancel(ctx, d.key) } + ConfirmButton(stringResource(R.string.stores_dl_cancel), stringResource(R.string.stores_dl_cancel_confirm), compact = true) { DownloadQueue.cancel(ctx, d.key) } } DownloadState.FAILED -> { - PrimaryButton(stringResource(R.string.stores_dl_retry), compact = true) { DownloadQueue.retry(ctx, d.key) } - SecondaryButton(stringResource(R.string.stores_dl_clear), compact = true) { DownloadQueue.dismiss(d.key) } + PrimaryButton(stringResource(R.string.stores_dl_resume), compact = true) { DownloadQueue.retry(ctx, d.key) } + ConfirmButton(stringResource(R.string.stores_dl_clear), stringResource(R.string.stores_dl_cancel_confirm), compact = true) { DownloadQueue.clear(ctx, d.key) } } DownloadState.INSTALLED -> { PrimaryButton(stringResource(R.string.stores_play), compact = true, icon = Icons.Filled.PlayArrow) { launchStoreGame(ctx, d.store, d.id, s, a) } @@ -175,15 +177,6 @@ private fun StageBar(d: DownloadEntry) { } } -@Composable -private fun activeStageLabel(stage: DownloadStage): String = when (stage) { - DownloadStage.MANIFEST -> stringResource(R.string.stores_stage_manifest) - DownloadStage.DOWNLOAD -> stringResource(R.string.stores_stage_downloading) - DownloadStage.VERIFY -> stringResource(R.string.stores_stage_verifying) - DownloadStage.INSTALL -> stringResource(R.string.stores_stage_installing) - DownloadStage.DONE -> stringResource(R.string.stores_stage_done) -} - private fun eta(seconds: Long): String = when { seconds < 60 -> "<1 min" seconds < 3600 -> "~${seconds / 60} min" diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt index f91aa715a..54b60f3c0 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt @@ -312,7 +312,7 @@ private fun Storefront( }, ) } - StoresState.status[store]?.let { Rise(3) { Text(it, fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 6.dp)) } } + StoresState.status[store]?.let { line -> Rise(3) { SyncBar(line) } } StoresState.problems[store]?.let { Rise(3) { Box(Modifier.padding(bottom = 8.dp)) { Note(it) } } } // The first card drawn on the tab is where a pad lands after a tab or chip change. var firstPlaced = false @@ -452,6 +452,21 @@ private fun GameCard(item: CatalogItem, store: Store, s: FrontEndState, a: Front } } +/** A store's sync as a thin bar and its count ("25/55"); a moving bar when it has no count yet. */ +@Composable +private fun SyncBar(line: String) { + val count = Regex("(\\d+)\\s*/\\s*(\\d+)").find(line) + val done = count?.groupValues?.get(1)?.toIntOrNull() + val total = count?.groupValues?.get(2)?.toIntOrNull()?.takeIf { it > 0 } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) { + Box(Modifier.weight(1f)) { + if (done != null && total != null) androidx.compose.material3.LinearProgressIndicator(progress = { (done.toFloat() / total).coerceIn(0f, 1f) }, modifier = Modifier.fillMaxWidth().height(3.dp)) + else androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(3.dp)) + } + if (done != null && total != null) Text("$done/$total", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + } +} + /** How tall the card's one line is, over the blurred foot of its art. */ private val CardStrip = 28.dp @@ -535,10 +550,28 @@ internal fun ProgressBarThin(fraction: Float, paused: Boolean = false, verify: B } @Composable -internal fun downloadLabel(state: DownloadState, stage: DownloadStage, percent: Int): String = when (state) { +internal fun downloadLabel(d: com.droiddeck.launcher.stores.download.DownloadEntry): String = when (d.state) { DownloadState.PAUSED -> stringResource(R.string.stores_dl_paused) DownloadState.QUEUED -> stringResource(R.string.stores_dl_queued) - else -> stringResource(R.string.stores_dl_stage_percent, stageLabel(stage), percent) + else -> if (d.stageFraction >= 0f) stringResource(R.string.stores_dl_active_percent, activeStageLabel(d), d.percent) else activeStageLabel(d) +} + +/** The active stage as the row and the page name it: Checking (files already there), Downloading, Verifying, Installing. */ +@Composable +internal fun activeStageLabel(d: com.droiddeck.launcher.stores.download.DownloadEntry): String = when (d.stage) { + DownloadStage.MANIFEST -> if (d.stageItemsTotal > 0) stringResource(R.string.stores_stage_checking) else stringResource(R.string.stores_stage_manifest) + DownloadStage.DOWNLOAD -> stringResource(R.string.stores_stage_downloading) + DownloadStage.VERIFY -> stringResource(R.string.stores_stage_verifying) + DownloadStage.INSTALL -> stringResource(R.string.stores_stage_installing) + DownloadStage.DONE -> stringResource(R.string.stores_stage_done) +} + +/** Two taps: the first arms it ([confirm] shows), the second acts; it disarms itself after a few seconds. */ +@Composable +internal fun ConfirmButton(label: String, confirm: String, compact: Boolean = false, onConfirm: () -> Unit) { + var armed by remember { mutableStateOf(false) } + LaunchedEffect(armed) { if (armed) { kotlinx.coroutines.delay(4000); armed = false } } + SecondaryButton(if (armed) confirm else label, compact = compact) { if (!armed) armed = true else { armed = false; onConfirm() } } } @Composable diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e60e2dcee..975f08870 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1991,7 +1991,7 @@ Search your library… Search everything on %1$s… Nothing installed - Fetching your library… + Loading No games Loading the store… No catalog @@ -2062,7 +2062,10 @@ Verify Install Done + Checking Downloading + Delete download? + Failed · %1$s kept Verifying Installing %1$s %2$d%% diff --git a/docs/progress-log.md b/docs/progress-log.md index e8a33d8b9..d0484ac3b 100644 --- a/docs/progress-log.md +++ b/docs/progress-log.md @@ -135,6 +135,17 @@ without it and Setup says "Stores engine not built"). when installed, Resume, the download percentage, the install size, or Free / price / discount. A thin bar over the art while downloading. Install, Resume, Uninstall, Get and Buy live on the game page; A on a card opens it. +- **Stages named for what they do; both sizes; Cancel deletes.** Epic's check of the files already + there is part of Manifest ("Checking 120/515"), never a 100% Verify before anything is fetched. + Install has its own write rate and ETA from the bytes the assembler writes, and files n/N. The + row shows the download (compressed) as "1.6 GB / 2.5 GB" and the size on disk beside the + location; Epic remembers the disk size for the card and page. Cancel asks twice ("Delete + download?") and removes everything the install wrote - folder, sidecar, `.chunks`, scratch, + `.gog_chunks` - except a finished install being repaired, which keeps itself; the row says + Cancelled for three seconds, then goes, and the game reads Install. Pause keeps everything. A + failed row reads "Failed · 2.1 GB kept" (the reason is in the log) with Resume and Clear (Clear + deletes like Cancel); the game page offers Resume install and Clear for an unfinished install. + Library sync is a thin bar and "25/55", no sentence. - **Per-stage progress** (device: Epic DOOMBLADE, 13 GB, ~3 min of assembly with a still bar). The entry carries the active stage's own count (amount + files) and which stages are passed; the Downloads row draws one segment per stage (Manifest, Download, Verify, Install), and its line