diff --git a/app/src/main/java/com/droiddeck/launcher/stores/SizeCache.kt b/app/src/main/java/com/droiddeck/launcher/stores/SizeCache.kt new file mode 100644 index 000000000..c89df6b52 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/SizeCache.kt @@ -0,0 +1,46 @@ +package com.droiddeck.launcher.stores + +import java.io.File +import org.json.JSONObject + +/** + * Game sizes as found, kept per game AND the version they were found for, in + * `filesDir/stores//sizes.json`: a size is never fetched twice for one version, and a + * new version (a library refresh brings it) simply misses and is looked up again. The file carries + * its format; one in another format is discarded, not read. + */ +class SizeCache(private val file: File) { + private val sizes = HashMap>() + + init { + runCatching { + val o = JSONObject(file.readText()) + if (o.optInt("format") == FORMAT) { + val s = o.getJSONObject("sizes") + for (k in s.keys()) s.optJSONObject(k)?.let { e -> sizes[k] = e.optString("v") to e.optLong("b") } + } + } + } + + @Synchronized + fun get(id: String, version: String): Long? = sizes[id]?.takeIf { it.first == version && it.second > 0 }?.second + + @Synchronized + fun put(id: String, version: String, bytes: Long) { + if (bytes <= 0) return + sizes[id] = version to bytes + runCatching { + file.parentFile?.mkdirs() + val s = JSONObject() + for ((k, v) in sizes) s.put(k, JSONObject().put("v", v.first).put("b", v.second)) + val tmp = File(file.path + ".tmp") + tmp.writeText(JSONObject().put("format", FORMAT).put("sizes", s).toString()) + tmp.renameTo(file) + } + } + + companion object { + const val FORMAT = 1 + fun forStore(filesDir: File, store: Store) = SizeCache(File(filesDir, "stores/${store.id}/sizes.json")) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt index fc42c96dd..c5c85378b 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt @@ -81,6 +81,18 @@ fun installedCards(store: Store, installed: List, library: L }.distinctBy { it.id }.sortedBy { it.title.lowercase() } } +/** + * A refreshed list with every unchanged item kept as the very object already shown, so a refresh + * that lands while the grid is on screen recomposes only the cards that changed - no reset, the + * scroll position and the pad's focus stay. + */ +fun mergeItems(old: List?, new: List): List { + if (old.isNullOrEmpty()) return new + val before = old.associateBy { it.key } + val merged = new.map { n -> before[n.key]?.takeIf { it == n } ?: n } + return if (merged.size == old.size && merged.indices.all { merged[it] === old[it] }) old else merged +} + class InstalledStoreGame(val sidecar: StoreGameSidecar, val folder: java.io.File) { val key: String get() = "${sidecar.store.id}:${sidecar.id}" } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt index 13b98d07f..122b3778f 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt @@ -33,14 +33,21 @@ object StoreSizes { 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. */ + private val caches = ConcurrentHashMap() + private fun cache(context: Context, store: Store) = caches.getOrPut(store) { SizeCache.forStore(context.applicationContext.filesDir, store) } + + /** A card for [item] is on screen (or about to be): its size from the cache for this version, else looked up. */ 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 + val version = item.extra["version"].orEmpty() pool.execute { + val cached = cache(app, item.store).get(item.id, version) + if (cached != null) { StoresState.post { known[item.key] = cached }; return@execute } val bytes = runCatching { fetch(app, item) }.onFailure { Log.w(TAG, "${item.key}: ${it.javaClass.simpleName}") }.getOrNull() ?: 0L if (bytes > 0) { + cache(app, item.store).put(item.id, version, bytes) 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() @@ -51,6 +58,21 @@ object StoreSizes { } } + /** An installed game's size on disk, by card key; worked out once per install. */ + val onDisk = mutableStateMapOf() + + fun requestDisk(context: Context, key: String, game: InstalledStoreGame) { + if (onDisk.containsKey(key) || !asked.add("disk:$key")) return + val app = context.applicationContext + val version = "disk:${game.sidecar.installedAt}:${game.sidecar.installVersion}" + pool.execute { + val c = cache(app, game.sidecar.store) + val bytes = c.get("disk:${game.sidecar.id}", version) + ?: game.folder.walkTopDown().filter { it.isFile && !it.name.startsWith(".droiddeck") }.sumOf { it.length() }.also { c.put("disk:${game.sidecar.id}", version, it) } + if (bytes > 0) StoresState.post { onDisk[key] = bytes } + } + } + private fun fetch(context: Context, item: CatalogItem): Long { return when (item.store) { Store.EPIC -> { val token = EpicCredentialStore.getValidAccessToken(context) ?: return 0L diff --git a/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonBackend.kt index 89179d258..d51a28410 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonBackend.kt @@ -66,7 +66,7 @@ object AmazonBackend : StoreBackend { /** The library and, from it, the shelves. */ private fun publish(app: Context, games: List) { val items = games.map { AmazonLibrary.toCatalogItem(app, it) } - StoresState.library[store] = items + StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], items) val installed = StoresState.installed.filter { it.sidecar.store == store }.map { it.sidecar.id }.toSet() StoresState.shelves[store] = StoreShelves(trending = items.filter { it.id !in installed }, free = emptyList(), deals = emptyList(), whatsNew = emptyList()) } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonLibrary.kt b/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonLibrary.kt index 630f57a6e..75331ba2c 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonLibrary.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonLibrary.kt @@ -22,7 +22,7 @@ object AmazonLibrary { private const val TAG = "AmazonLibrary" private const val CACHE_KEY = "library_cache" private const val LAST_SYNC_KEY = "library_synced_at" - private const val THROTTLE_MS = 15L * 60L * 1000L + private const val THROTTLE_MS = 6L * 60L * 60L * 1000L private const val POSTER_BUDGET = 40 private val syncing = AtomicBoolean(false) @@ -123,6 +123,6 @@ object AmazonLibrary { developer = g.developer, owned = true, sizeBytes = AmazonPrefs.get(context).getLong("size_${g.productId}", 0L), storeUrl = "https://gaming.amazon.com/home", - extra = mapOf("entitlementId" to g.entitlementId, "sku" to g.productSku), + extra = mapOf("entitlementId" to g.entitlementId, "sku" to g.productSku, "version" to (g.versionId ?: "")), ) } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt index 37439258e..41d4a2367 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt @@ -42,7 +42,7 @@ object EpicBackend : StoreBackend, EpicLaunchSupport { override fun syncLibrary(context: Context, force: Boolean) { val app = context.applicationContext val cached = EpicLibrary.cached(app) - if (cached.isNotEmpty()) StoresState.post { StoresState.library[store] = cached.map { EpicLibrary.toCatalogItem(app, it) } } + if (cached.isNotEmpty()) StoresState.post { StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], cached.map { EpicLibrary.toCatalogItem(app, it) }) } StoresState.post { StoresState.status[store] = "Fetching your library…" } Thread({ val result = EpicLibrary.sync(app, force) { line -> StoresState.post { StoresState.status[store] = line } } @@ -50,11 +50,11 @@ object EpicBackend : StoreBackend, EpicLaunchSupport { StoresState.status.remove(store) when (result) { is EpicLibrary.SyncResult.Ok -> { - StoresState.library[store] = result.games.map { EpicLibrary.toCatalogItem(app, it) }; StoresState.problems.remove(store) + StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], result.games.map { EpicLibrary.toCatalogItem(app, it) }); StoresState.problems.remove(store) } is EpicLibrary.SyncResult.Failed -> StoresState.problems[store] = result.message EpicLibrary.SyncResult.NotLoggedIn -> StoresState.problems[store] = "Epic session expired: sign in again." - EpicLibrary.SyncResult.Throttled -> if (!StoresState.library.containsKey(store)) StoresState.library[store] = cached.map { EpicLibrary.toCatalogItem(app, it) } + EpicLibrary.SyncResult.Throttled -> if (!StoresState.library.containsKey(store)) StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], cached.map { EpicLibrary.toCatalogItem(app, it) }) EpicLibrary.SyncResult.Busy -> {} } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicLibrary.kt b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicLibrary.kt index 1ef3c2891..5b623828c 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicLibrary.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicLibrary.kt @@ -19,7 +19,7 @@ object EpicLibrary { private const val TAG = "EpicLibrary" private const val CACHE_KEY = "library_cache" private const val LAST_SYNC_KEY = "library_synced_at" - private const val THROTTLE_MS = 15L * 60L * 1000L + private const val THROTTLE_MS = 6L * 60L * 60L * 1000L private val syncing = AtomicBoolean(false) @@ -114,6 +114,6 @@ object EpicLibrary { developer = g.developer, description = com.droiddeck.launcher.stores.cleanStoreText(g.description), owned = true, sizeBytes = EpicPrefs.get(context).getLong("size_${g.appName}", g.installSize), storeUrl = "https://store.epicgames.com/en-US/browse?q=${java.net.URLEncoder.encode(g.title, "UTF-8")}", - extra = mapOf("namespace" to g.namespace, "catalogItemId" to g.catalogItemId), + extra = mapOf("namespace" to g.namespace, "catalogItemId" to g.catalogItemId, "version" to (g.version ?: "")), ) } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicStoreCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicStoreCatalog.kt index 914a9cd65..6d4f2d95c 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicStoreCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicStoreCatalog.kt @@ -26,7 +26,7 @@ object EpicStoreCatalog { private const val TAG = "EpicStore" private const val GRAPHQL = "https://store.epicgames.com/graphql" private const val PROMOS = "https://store-site-backend-static-ipv4.ak.epicgames.com/freeGamesPromotions" - private const val FEATURED_TTL_MS = 30 * 60 * 1000L + private const val FEATURED_TTL_MS = 3 * 60 * 60 * 1000L private const val SEARCH_TTL_MS = 5 * 60 * 1000L private const val KEY_FEATURED = "store_featured" @@ -136,6 +136,8 @@ object EpicStoreCatalog { fun featured(context: Context, force: Boolean = false): StoreShelves? { val now = System.currentTimeMillis() featuredCache?.let { if (!force && now - it.at < FEATURED_TTL_MS) return it.value } + // Shelves saved on an earlier run are as good while they are young: shown, not fetched again. + if (!force && now - EpicPrefs.get(context).getLong("store_featured_at", 0L) < FEATURED_TTL_MS) cachedFeatured(context)?.let { return it } val freeNow = fetchPromos() val onSale = searchStore(24, sortBy = "currentPrice", sortDir = "ASC", onSale = true).filter { it.discountPercent > 0 } val newest = searchStore(24, sortBy = "releaseDate", sortDir = "DESC", releasedOnly = true) @@ -211,7 +213,7 @@ object EpicStoreCatalog { private fun persist(context: Context, f: StoreShelves) { runCatching { - EpicPrefs.get(context).edit().putString(KEY_FEATURED, JSONObject().put("new", listToJson(f.whatsNew)).put("deals", listToJson(f.deals)).put("free", listToJson(f.free)).put("trending", listToJson(f.trending)).toString()).apply() + EpicPrefs.get(context).edit().putString(KEY_FEATURED, JSONObject().put("new", listToJson(f.whatsNew)).put("deals", listToJson(f.deals)).put("free", listToJson(f.free)).put("trending", listToJson(f.trending)).toString()).putLong("store_featured_at", System.currentTimeMillis()).apply() } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt index 44e29ad10..0237d4de5 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt @@ -33,14 +33,14 @@ object GogBackend : StoreBackend { val app = context.applicationContext // The cache first, so the tab has something while the sync runs. val cached = GogLibrary.cached(app) - if (cached.isNotEmpty()) StoresState.post { StoresState.library[store] = cached.map { GogLibrary.toCatalogItem(app, it) } } + if (cached.isNotEmpty()) StoresState.post { StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], cached.map { GogLibrary.toCatalogItem(app, it) }) } StoresState.post { StoresState.status[store] = "Fetching your library…" } Thread({ val result = GogLibrary.sync(app, force) { line -> StoresState.post { StoresState.status[store] = line } } StoresState.post { StoresState.status.remove(store) when (result) { - is GogLibrary.SyncResult.Ok -> { StoresState.library[store] = result.games.map { GogLibrary.toCatalogItem(app, it) }; StoresState.problems.remove(store) } + is GogLibrary.SyncResult.Ok -> { StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], result.games.map { GogLibrary.toCatalogItem(app, it) }); StoresState.problems.remove(store) } is GogLibrary.SyncResult.Failed -> StoresState.problems[store] = result.message GogLibrary.SyncResult.NotLoggedIn -> StoresState.problems[store] = "GOG session expired: sign in again." GogLibrary.SyncResult.Busy -> {} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogLibrary.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogLibrary.kt index eceb6c38c..7012efaab 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogLibrary.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogLibrary.kt @@ -23,7 +23,7 @@ object GogLibrary { // v2: the card art moved from the store page's backdrop to the Galaxy library art; an older cache is re-fetched. private const val CACHE_KEY = "library_cache_v2" private const val LAST_SYNC_KEY = "library_synced_at" - private const val THROTTLE_MS = 15L * 60L * 1000L + private const val THROTTLE_MS = 6L * 60L * 60L * 1000L private val syncing = AtomicBoolean(false) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogStoreCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogStoreCatalog.kt index 57435c9d1..bad1caff4 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogStoreCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogStoreCatalog.kt @@ -24,7 +24,7 @@ import java.util.concurrent.ConcurrentHashMap object GogStoreCatalog { private const val TAG = "GogStore" private const val CATALOG = "https://catalog.gog.com/v1/catalog" - private const val FEATURED_TTL_MS = 30 * 60 * 1000L + private const val FEATURED_TTL_MS = 3 * 60 * 60 * 1000L private const val SEARCH_TTL_MS = 5 * 60 * 1000L // v2: entries carry the mature mark; an older cache would show everything until it aged out. private const val KEY_FEATURED = "store_featured_v2" @@ -61,6 +61,8 @@ object GogStoreCatalog { fun featured(context: Context, force: Boolean = false): StoreShelves? { val now = System.currentTimeMillis() featuredCache?.let { if (!force && now - it.at < FEATURED_TTL_MS) return it.value } + // Shelves saved on an earlier run are as good while they are young: shown, not fetched again. + if (!force && now - GogPrefs.get(context).getLong("store_featured_at", 0L) < FEATURED_TTL_MS) cachedFeatured(context)?.let { return it } val trending = fetchProducts(baseQuery(24, "desc:trending")) val newest = fetchProducts(baseQuery(40, "desc:releaseDate")).filter { !isFuture(it.releaseDate) }.take(24) val deals = fetchProducts(baseQuery(24, "desc:discount", "&discounted=eq:true")) @@ -195,7 +197,7 @@ object GogStoreCatalog { private fun persist(context: Context, f: StoreShelves) { runCatching { val o = JSONObject().put("trending", listToJson(f.trending)).put("new", listToJson(f.whatsNew)).put("deals", listToJson(f.deals)).put("free", listToJson(f.free)) - GogPrefs.get(context).edit().putString(KEY_FEATURED, o.toString()).apply() + GogPrefs.get(context).edit().putString(KEY_FEATURED, o.toString()).putLong("store_featured_at", System.currentTimeMillis()).apply() } } 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 7a554ff67..b9e648c0d 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt @@ -343,6 +343,8 @@ private fun Storefront( } }, trailing = { + // A purchase made elsewhere shows on a refresh; otherwise the library refreshes itself every six hours. + SmallTextButton(stringResource(R.string.stores_refresh)) { StoresState.open(ctx, store, force = true) } val name = StoresState.accounts[store] ?: "" SmallTextButton(stringResource(R.string.stores_signout_named, name)) { StoresState.signOut(ctx, store) } }, @@ -475,7 +477,13 @@ private fun Grid(items: List, empty: String, card: @Composable (Cat } } -private val CardWidth = 150.dp +/** + * A card's least width: shelf cards are this wide and a grid fits as many columns as the width + * allows at it (the columns then share the leftover), so a display-density setting changes the + * column count rather than the cards' size. 25% under the first 150 dp: five columns where there + * were four on the Pocket FIT. + */ +private val CardWidth = 112.dp /** * One title: its wide art, the title, a price or status line and one button that says the one @@ -529,7 +537,7 @@ private fun SyncBar(line: String) { } /** How tall the card's one line is, over the blurred foot of its art. */ -private val CardStrip = 28.dp +private val CardStrip = 26.dp /** * The card is its art: sharp above, and under the one line a blurred, darkened copy of the same @@ -574,7 +582,14 @@ private fun CardMarker(item: CatalogItem, installed: Boolean, download: com.droi val pal = LocalPalette.current val small = 10.sp when { - installed -> Icon(Icons.Filled.Check, stringResource(R.string.stores_installed_chip), tint = pal.good, modifier = Modifier.size(14.dp)) + // Installed: its size on disk and a small check after it. + installed -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) { + val ctx = LocalContext.current + val game = StoresState.installedGame(item.store, item.id) + if (game != null) androidx.compose.runtime.LaunchedEffect(item.key) { com.droiddeck.launcher.stores.StoreSizes.requestDisk(ctx, item.key, game) } + com.droiddeck.launcher.stores.StoreSizes.onDisk[item.key]?.let { Text(formatBytes(it), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1) } + Icon(Icons.Filled.Check, stringResource(R.string.stores_installed_chip), tint = pal.good, modifier = Modifier.size(11.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) // The size in the title's own type; nothing until it is known. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f2888572f..de7d1f2ef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2007,6 +2007,7 @@ In library Free Resume + Refresh Show mature content Epic EOS diff --git a/app/src/test/java/com/droiddeck/launcher/stores/StoreCacheTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/StoreCacheTest.kt new file mode 100644 index 000000000..a8e4dd20b --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/StoreCacheTest.kt @@ -0,0 +1,50 @@ +package com.droiddeck.launcher.stores + +import java.io.File +import java.nio.file.Files +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertNotSame +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Sizes kept per version, and a refresh that touches only the cards that changed. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class StoreCacheTest { + private val dir = Files.createTempDirectory("sizes").toFile() + + @Test fun aSizeIsKeptForItsVersionAndAskedAgainForANewOne() { + val cache = SizeCache.forStore(dir, Store.EPIC) + cache.put("Metalstorm", "1.0.3", 4_400_000_000L) + // Read back from disk by a new instance, as the next app start does. + val again = SizeCache.forStore(dir, Store.EPIC) + assertEquals(4_400_000_000L, again.get("Metalstorm", "1.0.3")) + assertNull(again.get("Metalstorm", "1.0.4")) + assertNull(again.get("Other", "1.0.3")) + } + + @Test fun aFileInAnotherFormatIsDiscarded() { + val file = File(dir, "stores/gog/sizes.json").apply { parentFile!!.mkdirs(); writeText("""{"format":0,"sizes":{"1":{"v":"a","b":5}}}""") } + assertNull(SizeCache(file).get("1", "a")) + } + + @Test fun aRefreshKeepsTheObjectsOfUnchangedCards() { + val a = CatalogItem(Store.GOG, "1", "A", imageUrl = "x", owned = true) + val b = CatalogItem(Store.GOG, "2", "B", imageUrl = "y", owned = true) + val old = listOf(a, b) + // Nothing changed: the very list shown stays. + assertSame(old, mergeItems(old, listOf(a.copy(), b.copy()))) + // One card changed: the other is the same object, the changed one is new. + val merged = mergeItems(old, listOf(a.copy(), b.copy(title = "B2"))) + assertSame(a, merged[0]) + assertNotSame(b, merged[1]) + assertEquals("B2", merged[1].title) + // A first load is the new list as it is. + val fresh = listOf(a) + assertSame(fresh, mergeItems(null, fresh)) + } +} diff --git a/docs/progress-log.md b/docs/progress-log.md index e585a3814..4c67247a6 100644 --- a/docs/progress-log.md +++ b/docs/progress-log.md @@ -153,6 +153,16 @@ 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). +- **Store cards a quarter smaller:** 112 dp least width (was 150) for shelf cards and grid cells; the + grid fits as many columns as the width allows and shares the rest, so a density setting changes + the column count; titles stay at 11 sp, art 16:9. Downloads rows unchanged. +- **Cache first, refreshed in the background:** a store's library shows from its saved copy at once + and is refreshed at most every six hours (or from the account row's Refresh), the result merged + so unchanged cards keep their objects - only changed cards recompose, the grid's scroll and the + pad's focus stay. Shelves saved on an earlier run count as fresh for three hours. Sizes are kept + per game and version in `filesDir/stores//sizes.json` (format-versioned) and looked up + again only for a new version. An installed card shows its size on disk with a small green check + after it. - **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