mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 11:31:08 +00:00
Stores: cache first, refreshed behind; sizes per version; smaller cards
A store's library shows from its saved copy at once and is refreshed in the background at most every six hours, or from the account row's Refresh. The result is merged so unchanged cards keep their objects: only changed cards recompose, and 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/<store>/ 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. Cards are a quarter smaller: 112 dp least width for shelf cards and grid cells, the grid fitting as many columns as the width allows, so a density setting changes the column count instead of the card size.
This commit is contained in:
1 parent
5109fa4839
commit
df88872c98
15 files changed
+179
-19
No files matched your search
@@ -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/<store>/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<String, Pair<String, Long>>()
|
||||
|
||||
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"))
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,18 @@ fun installedCards(store: Store, installed: List<InstalledStoreGame>, 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<CatalogItem>?, new: List<CatalogItem>): List<CatalogItem> {
|
||||
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}"
|
||||
}
|
||||
|
||||
@@ -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<Store, SizeCache>()
|
||||
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<String, Long>()
|
||||
|
||||
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
|
||||
|
||||
@@ -66,7 +66,7 @@ object AmazonBackend : StoreBackend {
|
||||
/** The library and, from it, the shelves. */
|
||||
private fun publish(app: Context, games: List<AmazonGame>) {
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -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 ?: "")),
|
||||
)
|
||||
}
|
||||
@@ -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 -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ?: "")),
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 -> {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<CatalogItem>, 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.
|
||||
|
||||
@@ -2007,6 +2007,7 @@
|
||||
<string name="stores_in_library">In library</string>
|
||||
<string name="stores_free">Free</string>
|
||||
<string name="stores_resume">Resume</string>
|
||||
<string name="stores_refresh">Refresh</string>
|
||||
<string name="stores_show_mature">Show mature content</string>
|
||||
<string name="epic_card_title">Epic</string>
|
||||
<string name="epic_card_eos">EOS</string>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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/<store>/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
|
||||
|
||||
Reference in new issue
Block a user