mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
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:
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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user