Stores: a download no longer slows the UI

Engines report per chunk or per file, and every report published a new
queue that the whole front end read - the rail badge was computed from
it in the root composition. Progress-only changes now reach the UI at
most four times a second (a stage or state change at once); each row and
card reads only its own entry; the list is replaced only when rows come,
go or change state; the badge count changes only when it changes; the
notification redraws at most once a second.

Download threads run at background priority - the queue's runner and
every manager's pool - and the tier leaves two cores for the UI. The
stores log is written in half-second batches.
This commit is contained in:
The412Banner committed 2026-10-08 22:35:52 -04:00
1 parent 1379396b99
commit 1a527e8ed1
12 files changed
+141 -35

No files matched your search

@@ -25,23 +25,39 @@ object StoreLogFiles {
fun dir(context: Context): File = File(context.applicationContext.filesDir, "logs/stores")
/** Appends one stamped line to today's file. Any thread. */
private val pending = java.util.concurrent.ConcurrentLinkedQueue<String>()
private val flushQueued = java.util.concurrent.atomic.AtomicBoolean(false)
private val flusher = java.util.concurrent.Executors.newSingleThreadScheduledExecutor { r ->
Thread({ android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND); r.run() }, "store-log-flush").apply { isDaemon = true }
}
/**
* Queues one stamped line for today's file. Any thread; nothing touches the disk here. Lines are
* written in batches, at most every half second, on [writer] in order.
*/
fun append(context: Context, line: String) {
val app = context.applicationContext
val now = Date()
writer.execute {
try {
val dir = dir(app).apply { mkdirs() }
val file = File(dir, "stores-${synchronized(DAY) { DAY.format(now) }}.log")
if (file.length() > MAX_BYTES) {
val old = File(dir, file.name + ".1")
old.delete()
file.renameTo(old)
}
file.appendText(line + "\n")
} catch (e: Exception) {
Log.w(TAG, "could not write the stores log: ${e.message}")
pending.add(line)
if (flushQueued.compareAndSet(false, true)) {
flusher.schedule({ flushQueued.set(false); writer.execute { flush(app) } }, 500, java.util.concurrent.TimeUnit.MILLISECONDS)
}
}
private fun flush(app: Context) {
if (pending.isEmpty()) return
val batch = StringBuilder()
while (true) { val l = pending.poll() ?: break; batch.append(l).append('\n') }
try {
val dir = dir(app).apply { mkdirs() }
val file = File(dir, "stores-${synchronized(DAY) { DAY.format(Date()) }}.log")
if (file.length() > MAX_BYTES) {
val old = File(dir, file.name + ".1")
old.delete()
file.renameTo(old)
}
file.appendText(batch.toString())
} catch (e: Exception) {
Log.w(TAG, "could not write the stores log: ${e.message}")
}
}
@@ -47,17 +47,47 @@ object StoresState {
var installed by mutableStateOf<List<InstalledStoreGame>>(emptyList())
private set
/** The download queue as the Downloads page lists it, newest first. */
/**
* The download queue as the Downloads page lists it, newest first. Replaced only when a row is
* added, removed, reordered or changes state - not on progress - so whatever iterates it does
* not recompose on every byte. A row's progress is read through [download].
*/
var downloads by mutableStateOf<List<DownloadEntry>>(emptyList())
internal set
private set
/** Downloads queued, running or paused across the stores (the rail badge); changes only when the count does. */
var activeDownloads by mutableStateOf(0)
private set
private val rows = HashMap<String, androidx.compose.runtime.MutableState<DownloadEntry?>>()
/** One row's live entry: a composable reading it recomposes for that row's progress only. Main thread. */
fun download(key: String): DownloadEntry? = rows.getOrPut(key) { mutableStateOf(null) }.value
/** Main thread: each row's holder set (equal entries change nothing), the list and the count only when they change. */
/** The newest list as published, progress included; for the notification, which is not composed. */
@Volatile var latestDownloads: List<DownloadEntry> = emptyList()
private set
private var notifiedAt = 0L
internal fun publishDownloads(list: List<DownloadEntry>) {
latestDownloads = list
// The notification follows at most once a second.
val now = android.os.SystemClock.uptimeMillis()
if (now - notifiedAt >= 1000) { notifiedAt = now; com.droiddeck.launcher.stores.download.StoreDownloadService.tick() }
val keys = list.mapTo(HashSet()) { it.key }
for (e in list) rows.getOrPut(e.key) { mutableStateOf(null) }.value = e
for ((k, holder) in rows) if (k !in keys) holder.value = null
val shape = { l: List<DownloadEntry> -> l.map { it.key to it.state } }
if (shape(list) != shape(downloads)) downloads = list
activeDownloads = list.count { it.isActive }
}
/** The native engine's version once probed, "" when the probe said it is missing, null before. */
var engine by mutableStateOf<String?>(null)
private set
/** Downloads queued, running or paused across the stores (the rail badge). */
val activeDownloads: Int get() = downloads.count { it.isActive }
private val backends = HashMap<Store, StoreBackend>()
@@ -99,7 +99,7 @@ public final class AmazonDownloadManager {
}
} else {
cb.onLog("amazon: engine=built-in (" + MAX_PARALLEL + " threads)");
ExecutorService pool = Executors.newFixedThreadPool(MAX_PARALLEL);
ExecutorService pool = Executors.newFixedThreadPool(MAX_PARALLEL, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("amazon-dl"));
List<Future<Boolean>> futures = new ArrayList<>();
for (AmazonManifest.ManifestFile file : files) {
final String dlUrl = spec.downloadUrl;
@@ -58,9 +58,11 @@ object DownloadQueue {
private fun enter(key: String, stage: DownloadStage, detail: String) {
synchronized(lock) {
val item = items[key] ?: return
val moved = item.entry.stage != stage || item.entry.state != DownloadState.RUNNING
transitionLocked(item, stage)
item.entry = item.entry.copy(detail = detail.ifEmpty { item.entry.detail }, state = DownloadState.RUNNING)
publishLocked()
// A new stage shows at once; a new line within the same stage (one per file) waits its turn.
publishLocked(progressOnly = !moved)
}
}
@@ -90,7 +92,7 @@ object DownloadQueue {
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()
publishLocked(progressOnly = true)
}
}
@@ -120,8 +122,9 @@ object DownloadQueue {
if (bytesDone >= 0) sample(item, done)
val speed = item.speedEwma.toLong()
val eta = if (speed > 0 && total > done) (total - done) / speed else -1L
val moved = e.stage != DownloadStage.DOWNLOAD || e.state != DownloadState.RUNNING
item.entry = e.copy(state = DownloadState.RUNNING, stage = DownloadStage.DOWNLOAD, bytesDone = done, bytesTotal = total, detail = detail ?: e.detail, speedBps = speed, etaSeconds = eta)
publishLocked()
publishLocked(progressOnly = !moved)
}
}
@@ -138,6 +141,18 @@ object DownloadQueue {
fun parallel(context: Context): Int = SessionPrefs.gameStoresParallel(context)
/** Threads for a store's download pool: background priority, so the UI and its render thread come first. */
@JvmStatic
fun workerFactory(name: String): java.util.concurrent.ThreadFactory {
val n = java.util.concurrent.atomic.AtomicInteger()
return java.util.concurrent.ThreadFactory { r ->
Thread({
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND)
r.run()
}, "$name-${n.incrementAndGet()}")
}
}
fun setParallel(context: Context, count: Int) {
SessionPrefs.setGameStoresParallel(context, count)
advance(context.applicationContext)
@@ -258,10 +273,33 @@ object DownloadQueue {
private fun publish(context: Context) { synchronized(lock) { publishLocked() }; StoreDownloadService.sync(context) }
/** Newest first: what was started last sits on top, as the preview lists them. Caller holds [lock]. */
private fun publishLocked() {
/** At most this often a progress-only change reaches the UI; a state or stage change goes at once. */
private const val PROGRESS_PUBLISH_MS = 250L
private var lastPublishAt = 0L
private var publishPending = false
private val main = android.os.Handler(android.os.Looper.getMainLooper())
/**
* Newest first: what was started last sits on top, as the preview lists them. Caller holds
* [lock]. Engines report per chunk or per file - hundreds of times a second at full speed - so
* [progressOnly] changes are coalesced to [PROGRESS_PUBLISH_MS]; the UI then recomposes a few
* times a second, not on every callback.
*/
private fun publishLocked(progressOnly: Boolean = false) {
val now = android.os.SystemClock.uptimeMillis()
if (progressOnly) {
val wait = lastPublishAt + PROGRESS_PUBLISH_MS - now
if (wait > 0) {
if (!publishPending) {
publishPending = true
main.postDelayed({ synchronized(lock) { publishPending = false; publishLocked() } }, wait)
}
return
}
}
lastPublishAt = now
val list = items.values.map { it.entry }.sortedByDescending { it.startedAt }
StoresState.post { StoresState.downloads = list }
StoresState.post { StoresState.publishDownloads(list) }
}
/** Starts queued items while slots are free. */
@@ -282,6 +320,9 @@ object DownloadQueue {
}
private fun runItem(context: Context, item: Item) {
// Downloads run beside the UI, never ahead of it: background priority for this thread and
// (through [workerFactory]) every pool a store's manager starts.
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND)
val handle = JobHandle(item.entry.key, item.cancelled)
var result: String? = null
var error: String? = null
@@ -52,7 +52,7 @@ class StoreDownloadService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
private fun build(): Notification {
val entries = StoresState.downloads.filter { it.state == DownloadState.RUNNING }
val entries = StoresState.latestDownloads.filter { it.state == DownloadState.RUNNING }
val first = entries.firstOrNull()
val title = if (active.size > 1) getString(R.string.stores_notification_many, active.size) else getString(R.string.stores_notification_one)
val text = when {
@@ -90,7 +90,7 @@ class StoreDownloadService : Service() {
/** A download started: the service is up (idempotent). */
fun start(context: Context) {
StoresState.downloads.filter { it.state == DownloadState.RUNNING }.forEach { active.add(it.key) }
StoresState.latestDownloads.filter { it.state == DownloadState.RUNNING }.forEach { active.add(it.key) }
try {
val app = context.applicationContext
val intent = Intent(app, StoreDownloadService::class.java)
@@ -100,6 +100,11 @@ class StoreDownloadService : Service() {
}
}
/** Progress moved: redraw the notification line (StoresState calls it at most once a second). */
fun tick() {
instance?.refresh()
}
/** The queue changed: redraw the notification line. */
fun sync(context: Context) {
instance?.refresh()
@@ -109,7 +114,7 @@ class StoreDownloadService : Service() {
fun finish(context: Context, key: String) {
active.remove(key)
val inst = instance ?: return
if (active.isEmpty() && StoresState.downloads.none { it.state == DownloadState.RUNNING }) inst.stopNow() else inst.refresh()
if (active.isEmpty() && StoresState.latestDownloads.none { it.state == DownloadState.RUNNING }) inst.stopNow() else inst.refresh()
}
}
}
@@ -19,7 +19,11 @@ enum class StoreDownloadTier(val id: String, @StringRes val label: Int, val netw
MAX("max", R.string.stores_tier_max, 96, 0.8);
/** Inflate / hash / write workers for this device, at least two so a fetch never waits on a single writer. */
val processWorkers: Int get() = (Runtime.getRuntime().availableProcessors() * decompressPerCore).toInt().coerceIn(2, 32)
/** Inflate / hash / write threads: by the tier, but two cores always left for the UI (at least 2 workers). */
val processWorkers: Int get() {
val cores = Runtime.getRuntime().availableProcessors()
return (cores * decompressPerCore).toInt().coerceIn(2, maxOf(2, minOf(32, cores - 2)))
}
companion object {
val ALL: List<StoreDownloadTier> = entries
@@ -225,7 +225,7 @@ public final class EpicDownloadManager {
}
if (javaPool) {
cb.onLog("epic: engine=built-in (8 threads)");
ExecutorService pool = Executors.newFixedThreadPool(8);
ExecutorService pool = Executors.newFixedThreadPool(8, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("epic-dl"));
for (ChunkInfo chunk : needed) {
final ChunkInfo fc = chunk;
pool.submit(() -> {
@@ -216,7 +216,7 @@ public final class GogDownloadManager {
cancelled, anyFailed, doneCount, total, planned, totalBytes, lastSpeedMs, lastSpeedB, speedBps, cb);
} else {
cb.onLog("gog: engine=built-in (" + threads + " threads)");
ExecutorService pool = Executors.newFixedThreadPool(threads);
ExecutorService pool = Executors.newFixedThreadPool(threads, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("gog-dl"));
List<Future<Void>> futures = new ArrayList<>();
for (DepotFile df : files) {
futures.add(pool.submit((Callable<Void>) () -> {
@@ -364,7 +364,7 @@ public final class GogDownloadManager {
new AtomicReference<>(""), null, null, new AtomicInteger(0), 0, threads, threads, false, "gog gen1=" + game.gameId,
cancelled, anyFailed, done, total, planned, totalBytes, lastSpeedMs, lastSpeedB, speedBps, cb);
} else {
ExecutorService pool = Executors.newFixedThreadPool(threads);
ExecutorService pool = Executors.newFixedThreadPool(threads, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("gog-dl"));
List<Future<Void>> futures = new ArrayList<>();
for (Gen1File gf : files) {
futures.add(pool.submit((Callable<Void>) () -> {
@@ -48,7 +48,7 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
val id = key.substringAfter(':')
val item = (StoresState.library[store].orEmpty() + StoresState.shelves[store]?.all.orEmpty()).firstOrNull { it.id == id }
val installed = StoresState.installedGame(store, id)
val download = StoresState.downloads.firstOrNull { it.store == store && it.id == id && it.isActive }
val download = StoresState.download("${store.id}:$id")?.takeIf { it.isActive }
// Removal asks twice: one stray press of A should not cost a download.
var confirmRemove by remember(key) { mutableStateOf(false) }
LaunchedEffect(confirmRemove) { if (confirmRemove) { kotlinx.coroutines.delay(4000); confirmRemove = false } }
@@ -76,7 +76,8 @@ private fun DownloadList(s: FrontEndState, a: FrontEndActions) {
return
}
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
entries.forEachIndexed { i, d -> key(d.key) { Rise(1 + i.coerceAtMost(5)) { DownloadCard(d, s, a) } } }
// The list changes only when rows come, go or change state; each row reads its own progress.
entries.forEachIndexed { i, row -> key(row.key) { Rise(1 + i.coerceAtMost(5)) { DownloadCard(StoresState.download(row.key) ?: row, s, a) } } }
}
}
@@ -442,7 +442,7 @@ private fun GameCard(item: CatalogItem, store: Store, s: FrontEndState, a: Front
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.985f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "cardScale")
val installed = item.id in installedKeys
val download = StoresState.downloads.firstOrNull { it.store == store && it.id == item.id && it.isActive }
val download = StoresState.download("${store.id}:${item.id}")?.takeIf { it.isActive }
val open = { onOpen(item.key) }
Column(
modifier = Modifier.fillMaxWidth().paneItem("card:${item.key}").then(if (first) Modifier.firstTile() else Modifier).graphicsLayer { scaleX = scale; scaleY = scale }
+9
View File
@@ -144,6 +144,15 @@ without it and Setup says "Stores engine not built").
(`token`, `__token__`, `f_token`, `hdnts`, access/refresh/id tokens, `code=`) and Authorization /
Cookie headers are blanked outside URLs too. `StoreLogTest` covers a GOG secure link and an Epic
Akamai URL. The engine's own logcat lines (tag `EpicNative` etc.) are written on the native side.
- **UI slow while downloading (device):** engines report per chunk or file, and each report
published a new queue that the whole front end read (the rail badge was computed from it in the
root composition). Now progress-only changes reach the UI at most four times a second (stage and
state changes at once), each row and card reads only its own entry, the list replaces itself only
when rows come, go or change state, the badge count changes only when it changes, and the
notification redraws at most once a second. Download threads run at background priority (the
queue's runner and every Java pool), the tier leaves two cores free (`processWorkers` capped at
cores - 2, at least 2), and the stores log is written in half-second batches. The native engine's
own threads need the same priority on its side.
- **Installed tab empty under "Installed (2)" (device):** the count came from the installs on disk,
the grid from the library filtered by id. Both now come from `installedCards`: each install joined
to its library item by id (or title), else a card from the sidecar's title and art.