Stores: every owned card shows its size

Epic's and Amazon's library answers carry no size, so their cards
showed none. A card on screen, or close to it, now has its size looked
up once - Epic's from the build manifest (the files this device
installs), Amazon's from its download manifest - three at a time in the
background, and kept in the store's prefs where the library reads it.
The size sits at the right of the title strip in the title's own type;
nothing shows until it is known, and a marker keeps the slot.
This commit is contained in:
The412Banner committed 2026-10-09 09:49:23 -04:00
1 parent f5c343bde0
commit 3c64c6b3b8
3 files changed
+94 -2

No files matched your search

@@ -0,0 +1,74 @@
package com.droiddeck.launcher.stores
import android.content.Context
import android.util.Log
import androidx.compose.runtime.mutableStateMapOf
import com.droiddeck.launcher.stores.amazon.AmazonApiClient
import com.droiddeck.launcher.stores.amazon.AmazonCredentialStore
import com.droiddeck.launcher.stores.amazon.AmazonManifest
import com.droiddeck.launcher.stores.amazon.AmazonPrefs
import com.droiddeck.launcher.stores.epic.EpicApiClient
import com.droiddeck.launcher.stores.epic.EpicCredentialStore
import com.droiddeck.launcher.stores.epic.EpicDownloadManager
import com.droiddeck.launcher.stores.epic.EpicInstallTags
import com.droiddeck.launcher.stores.epic.EpicPrefs
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
/**
* Install sizes for owned Epic and Amazon games, whose library answers carry none: Epic's from the
* game's build manifest (the files this device installs), Amazon's from its download manifest.
* Asked for a card as it comes on screen, three at a time in the background, once per game - the
* answer is kept in the store's prefs (`size_<id>`, where the library reads it too). Until it is
* known the card shows nothing.
*/
object StoreSizes {
private const val TAG = "StoreSizes"
/** Sizes found this run, by card key; the cards read them. */
val known = mutableStateMapOf<String, Long>()
private val asked = ConcurrentHashMap.newKeySet<String>()
private val pool = Executors.newFixedThreadPool(3, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("sizes"))
fun size(item: CatalogItem): Long = known[item.key] ?: item.sizeBytes
/** A card for [item] is on screen (or about to be): find its size if nothing has yet. */
fun request(context: Context, item: CatalogItem) {
if (!item.owned || item.sizeBytes > 0 || item.store == Store.GOG) return
if (known.containsKey(item.key) || !asked.add(item.key)) return
val app = context.applicationContext
pool.execute {
val bytes = runCatching { fetch(app, item) }.onFailure { Log.w(TAG, "${item.key}: ${it.javaClass.simpleName}") }.getOrNull() ?: 0L
if (bytes > 0) {
when (item.store) {
Store.EPIC -> EpicPrefs.get(app).edit().putLong("size_${item.id}", bytes).apply()
Store.AMAZON -> AmazonPrefs.get(app).edit().putLong("size_${item.id}", bytes).apply()
Store.GOG -> {}
}
StoresState.post { known[item.key] = bytes }
}
}
}
private fun fetch(context: Context, item: CatalogItem): Long = when (item.store) {
Store.EPIC -> {
val token = EpicCredentialStore.getValidAccessToken(context) ?: return 0L
val ns = item.extra["namespace"].orEmpty()
val catalogItemId = item.extra["catalogItemId"].orEmpty()
if (ns.isEmpty() || catalogItemId.isEmpty()) return 0L
val json = EpicApiClient.getManifestApiJson(token, ns, catalogItemId, item.id) ?: return 0L
val bytes = EpicDownloadManager.downloadManifest(json, EpicDownloadManager.parseCdnUrls(json)) ?: return 0L
val manifest = EpicDownloadManager.parseManifest(bytes) ?: return 0L
EpicDownloadManager.resolveInstallFiles(manifest, EpicInstallTags.tagsForDevice()).sumOf { it.fileSize() }
}
Store.AMAZON -> {
val token = AmazonCredentialStore.getValidAccessToken(context) ?: return 0L
val entitlement = item.extra["entitlementId"].orEmpty().ifEmpty { return 0L }
val spec = AmazonApiClient.getGameDownload(token, entitlement) ?: return 0L
val bytes = AmazonApiClient.getBytes(AmazonApiClient.appendPath(spec.downloadUrl, "manifest.proto"), token) ?: return 0L
AmazonManifest.parse(bytes).totalInstallSize
}
Store.GOG -> 0L
}
}
@@ -1,5 +1,7 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.draw.drawBehind
@@ -492,8 +494,18 @@ private fun GameCard(item: CatalogItem, store: Store, s: FrontEndState, a: Front
val installed = item.id in installedKeys
val download = StoresState.download("${store.id}:${item.id}")?.takeIf { it.isActive }
val open = { onOpen(item.key) }
// A size the library did not give is looked up once the card is on screen or close to it.
val screen = with(androidx.compose.ui.platform.LocalDensity.current) { androidx.compose.ui.platform.LocalConfiguration.current.screenHeightDp.dp.toPx() }
val ahead = screen / 2
Column(
modifier = Modifier.fillMaxWidth().paneItem("card:${item.key}").then(if (first) Modifier.firstTile() else Modifier).graphicsLayer { scaleX = scale; scaleY = scale }
modifier = Modifier.fillMaxWidth()
.onGloballyPositioned { c ->
if (item.owned && item.sizeBytes <= 0) {
val b = c.boundsInWindow()
if (b.bottom > -ahead && b.top < screen + ahead && b.width > 0f) com.droiddeck.launcher.stores.StoreSizes.request(ctx, item)
}
}
.paneItem("card:${item.key}").then(if (first) Modifier.firstTile() else Modifier).graphicsLayer { scaleX = scale; scaleY = scale }
.clip(RoundedCornerShape(10.dp)).glideBorder(hot, RoundedCornerShape(10.dp), pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = open).controllerConfirm(onClick = open),
) {
@@ -565,7 +577,8 @@ private fun CardMarker(item: CatalogItem, installed: Boolean, download: com.droi
installed -> Icon(Icons.Filled.Check, stringResource(R.string.stores_installed_chip), tint = pal.good, modifier = Modifier.size(14.dp))
download != null -> Text("${download.percent}%", fontSize = small, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1)
item.owned && StoresState.isUnfinished(item) -> Text(stringResource(R.string.stores_resume), fontSize = small, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1)
item.owned -> if (item.sizeBytes > 0) Text(formatBytes(item.sizeBytes), fontSize = small, color = Color.White.copy(alpha = 0.8f), maxLines = 1)
// The size in the title's own type; nothing until it is known.
item.owned -> com.droiddeck.launcher.stores.StoreSizes.size(item).takeIf { it > 0 }?.let { Text(formatBytes(it), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1) }
item.isFree -> Text(stringResource(R.string.stores_free), fontSize = small, fontWeight = FontWeight.Bold, color = pal.good, maxLines = 1)
item.isDiscounted -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
StateChip("-${item.discountPercent}%", ChipTone.DEAL, small = true)
+5
View File
@@ -153,6 +153,11 @@ without it and Setup says "Stores engine not built").
- **Cloud saves device-proven (AYANEO Pocket FIT, `6a175f1`, ELDERBORN on GOG):** the launch logged
`cloud gog 1732383191 down result=ok files=5 bytes=237499 reason=downloaded` (template found) and
the in-game slot shows the cloud save (Catacombs: Tutorial part 1, 00:08:40).
- **Every owned card shows its size** at the right of its title strip, in the title's type: GOG's from
its catalog; Epic's (build manifest, the files this device installs) and Amazon's (download
manifest) looked up once a card is on screen or close, three at a time in the background, kept in
the store's prefs (`size_<id>`). Nothing shows until it is known; a marker (installed, Resume, a
download's percent) keeps the slot.
- **Two tabs, Store and Library** (the All count mixed owned games with whatever shelves had loaded:
GOG Library 33 / All 120): Library's dropdown filters All (every owned game, the default, the
tab's count) or Installed, and the tab reads the filter ("Library (33)", "Installed (1)"); the old