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.
This commit is contained in:
The412Banner committed 2026-10-08 21:01:17 -04:00
1 parent a5f608b9d7
commit cb2f7c0109
14 files changed
+171 -57

No files matched your search

@@ -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<File>) {
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()
@@ -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. */
@@ -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())
}
}
@@ -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,
) {
@@ -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. */
@@ -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)))
}
}
@@ -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<FileInfo> 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");
@@ -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")))
}
}
}
@@ -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();
}
@@ -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) }
@@ -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"
@@ -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
+4 -1
View File
@@ -1991,7 +1991,7 @@
<string name="stores_search_library">Search your library…</string>
<string name="stores_search_all">Search everything on %1$s…</string>
<string name="stores_installed_empty">Nothing installed</string>
<string name="stores_library_loading">Fetching your library…</string>
<string name="stores_library_loading">Loading</string>
<string name="stores_nothing_matches">No games</string>
<string name="stores_shelves_loading">Loading the store…</string>
<string name="stores_amazon_no_catalog">No catalog</string>
@@ -2062,7 +2062,10 @@
<string name="stores_stage_verify">Verify</string>
<string name="stores_stage_install">Install</string>
<string name="stores_stage_done">Done</string>
<string name="stores_stage_checking">Checking</string>
<string name="stores_stage_downloading">Downloading</string>
<string name="stores_dl_cancel_confirm">Delete download?</string>
<string name="stores_dl_failed_kept">Failed · %1$s kept</string>
<string name="stores_stage_verifying">Verifying</string>
<string name="stores_stage_installing">Installing</string>
<string name="stores_dl_active_percent">%1$s %2$d%%</string>
+11
View File
@@ -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