diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt new file mode 100644 index 000000000..e2cf47489 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt @@ -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_`, 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() + + private val asked = ConcurrentHashMap.newKeySet() + 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 + } +} 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 78af32e3e..7a554ff67 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt @@ -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) diff --git a/docs/progress-log.md b/docs/progress-log.md index 49a7a4782..e585a3814 100644 --- a/docs/progress-log.md +++ b/docs/progress-log.md @@ -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_`). 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