feat: store games get their logo from the store

GOG (api.gog.com v2 `_links.logo`), Epic (catalog keyImage `DieselGameBoxLogo`)
and Amazon (`productDetail.details.logoUrl`) logos are kept in the install
record as an optional `logo` field and written next to the game as logo.png
when the install finishes. The PNG is kept as it came, transparency included,
and anything that does not decode as an image is dropped. Older install records
without the field still read as before.
This commit is contained in:
ovurrsl committed 2026-10-19 15:12:46 +03:00
1 parent 5970de928e
commit 8ceff5641a
15 files changed
+193 -12

No files matched your search

@@ -14,13 +14,45 @@ object StoreArt {
fun fetchInto(folder: File, sidecar: StoreGameSidecar) {
sidecar.cover?.let { place(it, File(folder, "cover.jpg")) }
val wide = sidecar.hero ?: return
if (place(wide, File(folder, "hero.jpg"))) {
val header = File(folder, "header.jpg")
if (!header.isFile) runCatching { File(folder, "hero.jpg").copyTo(header, overwrite = true) }
sidecar.hero?.let { wide ->
if (place(wide, File(folder, "hero.jpg"))) {
val header = File(folder, "header.jpg")
if (!header.isFile) runCatching { File(folder, "hero.jpg").copyTo(header, overwrite = true) }
}
}
sidecar.logo?.let { fetchLogo(folder, it) }
}
fun fetchLogo(folder: File, url: String): Boolean {
val dst = File(folder, LOGO)
if (dst.isFile && dst.length() > 0) return true
return writeLogo(get(url) ?: return false, dst)
}
/** A logo is drawn over the hero, so it stays a PNG with its transparency instead of going through [place]. */
internal fun writeLogo(bytes: ByteArray, dst: File): Boolean {
if (dst.isFile && dst.length() > 0) return true
val bitmap = runCatching { BitmapFactory.decodeByteArray(bytes, 0, bytes.size) }.getOrNull() ?: return false
val tmp = File(dst.path + ".tmp")
return try {
if (bitmap.width <= 0 || bitmap.height <= 0) return false
if (isPng(bytes)) tmp.writeBytes(bytes) else tmp.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
tmp.renameTo(dst)
} catch (e: Exception) {
Log.w(TAG, "${dst.name}: ${e.javaClass.simpleName}")
false
} finally {
tmp.delete()
bitmap.recycle()
}
}
private val PNG_MAGIC = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)
private fun isPng(bytes: ByteArray): Boolean = bytes.size > PNG_MAGIC.size && PNG_MAGIC.indices.all { bytes[it] == PNG_MAGIC[it] }
const val LOGO = "logo.png"
private fun place(url: String, dst: File): Boolean {
if (dst.isFile && dst.length() > 0) return true
val bytes = get(url) ?: return false
@@ -21,6 +21,7 @@ data class CatalogItem(
val sizeBytes: Long = 0L,
val extra: Map<String, String> = emptyMap(),
val mature: Boolean = false,
val logoUrl: String? = null,
) {
val isDiscounted: Boolean get() = discountPercent > 0 && originalPrice.isNotBlank()
val key: String get() = "${store.id}:$id"
@@ -22,6 +22,7 @@ class StoreGameSidecar(
val epic: EpicOptions = EpicOptions(),
val cloud: Boolean = true,
val achievements: Boolean = true,
val logo: String? = null,
) {
val isInstalled: Boolean get() = state == STATE_INSTALLED
@@ -33,7 +34,8 @@ class StoreGameSidecar(
env: Map<String, String> = this.env, installVersion: String = this.installVersion,
installedAt: Long = this.installedAt, cover: String? = this.cover, hero: String? = this.hero,
extra: Map<String, String> = this.extra, state: String = this.state, epic: EpicOptions = this.epic, cloud: Boolean = this.cloud, achievements: Boolean = this.achievements,
) = StoreGameSidecar(store, id, title, exe, launcher, args, env, installVersion, installedAt, cover, hero, extra, state, epic, cloud, achievements)
logo: String? = this.logo,
) = StoreGameSidecar(store, id, title, exe, launcher, args, env, installVersion, installedAt, cover, hero, extra, state, epic, cloud, achievements, logo)
fun toJson(): JSONObject = JSONObject().apply {
put("version", VERSION)
@@ -52,6 +54,7 @@ class StoreGameSidecar(
if (installedAt > 0L) put("installedAt", installedAt)
if (!cover.isNullOrEmpty()) put("cover", cover)
if (!hero.isNullOrEmpty()) put("hero", hero)
if (!logo.isNullOrEmpty()) put("logo", logo)
if (extra.isNotEmpty()) put("extra", JSONObject(extra as Map<*, *>))
}
@@ -129,6 +132,7 @@ class StoreGameSidecar(
epic = o.optJSONObject("epic")?.let { e -> EpicOptions(eos = e.optBoolean("eos", true), offline = e.optBoolean("offline", false)) } ?: EpicOptions(),
cloud = o.optBoolean("cloud", true),
achievements = o.optBoolean("achievements", true),
logo = o.optString("logo", "").ifEmpty { null },
)
}
@@ -40,7 +40,7 @@ object StoreInstalls {
val app = context.applicationContext
onStep?.invoke(0, STEPS)
val before = StoreGameSidecar.read(folder)
val finished = StoreLaunch.writeLauncher(folder, sidecar.copy(state = StoreGameSidecar.STATE_INSTALLED, epic = before?.epic ?: sidecar.epic, cloud = before?.cloud ?: sidecar.cloud, achievements = before?.achievements ?: sidecar.achievements))
val finished = StoreLaunch.writeLauncher(folder, sidecar.copy(state = StoreGameSidecar.STATE_INSTALLED, epic = before?.epic ?: sidecar.epic, cloud = before?.cloud ?: sidecar.cloud, achievements = before?.achievements ?: sidecar.achievements, logo = sidecar.logo ?: before?.logo))
finished.write(folder)
if (StoreGameSidecar.read(folder)?.isInstalled != true) throw java.io.IOException("the install record could not be written in ${folder.name}")
onStep?.invoke(1, STEPS)
@@ -124,6 +124,7 @@ internal class AmazonInstaller(
installVersion = versionId, installedAt = System.currentTimeMillis(),
cover = cover, hero = item.imageUrl,
extra = mapOf("entitlementId" to entitlementId, "sku" to sku),
logo = item.logoUrl,
)
StoreInstalls.complete(app, folder, sidecar, onStep = { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) })
return folder.path
@@ -24,6 +24,7 @@ data class AmazonGame(
val publisher: String = "",
val productSku: String = "",
val versionId: String = "",
val logoUrl: String = "",
)
object AmazonLibrary {
@@ -42,7 +43,8 @@ object AmazonLibrary {
JSONObject()
.put("productId", g.productId).put("entitlementId", g.entitlementId).put("title", g.title)
.put("artUrl", g.artUrl).put("heroUrl", g.heroUrl).put("developer", g.developer)
.put("publisher", g.publisher).put("productSku", g.productSku).put("versionId", g.versionId),
.put("publisher", g.publisher).put("productSku", g.productSku).put("versionId", g.versionId)
.apply { if (g.logoUrl.isNotEmpty()) put("logoUrl", g.logoUrl) },
)
}.toString()
@@ -60,6 +62,7 @@ object AmazonLibrary {
publisher = o.optString("publisher", ""),
productSku = o.optString("productSku", ""),
versionId = o.optString("versionId", ""),
logoUrl = o.optString("logoUrl", ""),
)
}
return tidy(games)
@@ -94,6 +97,7 @@ object AmazonLibrary {
sizeBytes = prefs.getLong("size_${game.productId}", 0L).takeIf { it > 0 } ?: 0L,
storeUrl = STORE_URL,
extra = mapOf("entitlementId" to game.entitlementId, "sku" to game.productSku, "version" to ""),
logoUrl = game.logoUrl.ifEmpty { null },
)
}
@@ -106,7 +110,7 @@ object AmazonLibrary {
}
}
private fun gameFrom(o: JSONObject, before: AmazonGame?): AmazonGame = AmazonGame(
internal fun gameFrom(o: JSONObject, before: AmazonGame?): AmazonGame = AmazonGame(
productId = o.optString("productId"),
entitlementId = o.optString("entitlementId"),
title = o.optString("title"),
@@ -116,6 +120,7 @@ object AmazonLibrary {
publisher = o.optString("publisher"),
productSku = o.optString("sku"),
versionId = before?.versionId.orEmpty(),
logoUrl = o.optString("logo").ifEmpty { before?.logoUrl.orEmpty() },
)
private fun backfillPosters(app: Context, games: List<AmazonGame>) {
@@ -77,6 +77,7 @@ object SavedQueue {
private fun itemJson(i: CatalogItem) = JSONObject().apply {
put("store", i.store.id); put("id", i.id); put("title", i.title)
put("image", i.imageUrl ?: ""); put("tall", i.tallImageUrl ?: "")
i.logoUrl?.let { put("logo", it) }
put("tags", i.tags); put("developer", i.developer); put("description", i.description)
put("size", i.sizeBytes); put("owned", i.owned)
put("extra", JSONObject(i.extra))
@@ -91,6 +92,7 @@ object SavedQueue {
tags = o.optString("tags"), developer = o.optString("developer"), description = o.optString("description"),
owned = o.optBoolean("owned", true), sizeBytes = o.optLong("size"),
extra = extra?.keys()?.asSequence()?.associateWith { extra.getString(it) } ?: emptyMap(),
logoUrl = o.optString("logo").ifEmpty { null },
)
}
}
@@ -129,7 +129,7 @@ internal class EpicInstaller(
val sidecar = StoreGameSidecar(
Store.EPIC, item.id, item.title, exe = picked.ifEmpty { "game.exe" }, args = args,
installVersion = result.optString("buildVersion"), installedAt = System.currentTimeMillis(),
cover = cover, hero = item.imageUrl, extra = extra,
cover = cover, hero = item.imageUrl, extra = extra, logo = item.logoUrl,
)
StoreInstalls.complete(app, folder, sidecar, onStep = { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) })
val disk = result.optLong("diskBytes", 0L)
@@ -25,6 +25,7 @@ data class EpicGame(
val releaseDate: String = "",
val publisher: String = "",
val isDlc: Boolean = false,
val artLogo: String = "",
)
object EpicLibrary {
@@ -50,7 +51,8 @@ object EpicLibrary {
.put("title", g.title).put("artCover", g.artCover).put("artSquare", g.artSquare)
.put("developer", g.developer).put("description", g.description).put("version", g.version)
.put("installSize", g.installSize).put("canRunOffline", g.canRunOffline)
.put("releaseDate", g.releaseDate).put("publisher", g.publisher),
.put("releaseDate", g.releaseDate).put("publisher", g.publisher)
.apply { if (g.artLogo.isNotEmpty()) put("artLogo", g.artLogo) },
)
}.toString()
@@ -73,6 +75,7 @@ object EpicLibrary {
canRunOffline = o.optBoolean("canRunOffline", true),
releaseDate = o.optString("releaseDate", ""),
publisher = o.optString("publisher", ""),
artLogo = o.optString("artLogo", ""),
)
}
return tidy(games)
@@ -106,6 +109,7 @@ object EpicLibrary {
sizeBytes = prefs.getLong("size_${game.appName}", 0L).takeIf { it > 0 } ?: game.installSize,
storeUrl = "https://store.epicgames.com/en-US/browse?q=" + URLEncoder.encode(title, "UTF-8"),
extra = mapOf("namespace" to game.namespace, "catalogItemId" to game.catalogItemId, "version" to game.version),
logoUrl = game.artLogo.ifEmpty { null },
)
}
@@ -128,6 +132,7 @@ object EpicLibrary {
releaseDate = pick("releaseDate", before?.releaseDate.orEmpty()),
publisher = pick("publisher", before?.publisher.orEmpty()),
isDlc = d.optBoolean("dlc", false),
artLogo = pick("logo", before?.artLogo.orEmpty()),
)
}
@@ -10,10 +10,12 @@ import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstallRoot
import com.droiddeck.launcher.stores.StoreInstalls
import com.droiddeck.launcher.stores.StoreLoginActivity
import com.droiddeck.launcher.stores.StoreNet
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.download.DownloadEntry
import com.droiddeck.launcher.stores.download.DownloadQueue
import com.droiddeck.launcher.stores.download.DownloadStage
import org.json.JSONObject
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
@@ -75,6 +77,13 @@ object GogBackend : StoreBackend {
override fun uninstall(context: Context, game: InstalledStoreGame) {}
private fun fetchLogo(id: String): String? =
runCatching { StoreNet.get("https://api.gog.com/v2/games/$id?locale=en-US")?.let { logoOf(JSONObject(it)) } }.getOrNull()
/** `images.logo2x` from the products API is an opaque banner, not a logo; the v2 `logo` link is a transparent PNG. */
internal fun logoOf(v2: JSONObject): String? =
GogStoreCatalog.absolutize(v2.optJSONObject("_links")?.optJSONObject("logo")?.optString("href")).takeIf { it.startsWith("https://") }
private class InstallJob(val app: Context, val game: GogGame, val item: CatalogItem, val folder: File) : DownloadQueue.DownloadJob {
private val cancelled = AtomicBoolean(false)
@@ -106,6 +115,7 @@ object GogBackend : StoreBackend {
Store.GOG, game.gameId, game.title, exe = result.exeRelative.ifEmpty { "" },
installVersion = result.buildId, installedAt = System.currentTimeMillis(),
cover = item.tallImageUrl ?: item.imageUrl, hero = item.imageUrl,
logo = item.logoUrl ?: fetchLogo(game.gameId),
extra = mapOf(com.droiddeck.launcher.frontend.WinCompSources.GOG_DEPENDENCIES to result.dependencies.joinToString(","))
+ (if (result.dependenciesRead) mapOf(com.droiddeck.launcher.frontend.WinCompSources.LIST_READ to "1") else emptyMap()),
)
@@ -21,6 +21,7 @@ pub struct Game {
pub parent_product_id: String,
pub art: String,
pub hero: String,
pub logo: String,
pub developer: String,
pub publisher: String,
}
@@ -39,6 +40,7 @@ impl Game {
"sku": self.sku,
"art": self.art,
"hero": self.hero,
"logo": self.logo,
"developer": self.developer,
"publisher": self.publisher,
"dlc": self.is_dlc(),
@@ -83,6 +85,7 @@ pub fn read_entitlement(e: &Value) -> Option<Game> {
],
)
.to_string(),
logo: str_at(product, &["productDetail", "details", "logoUrl"]).to_string(),
developer: str_at(product, &["productDetail", "details", "developer"]).to_string(),
publisher: str_at(product, &["productDetail", "details", "publisher"]).to_string(),
})
@@ -178,17 +181,20 @@ mod tests {
fn entitlement_fields_and_fallbacks() {
let e = json!({"id":"ent-1","product":{"id":"amzn1.adg.product.x","title":"Game","sku":"SKU1",
"productDetail":{"iconUrl":"","details":{"pgCrownImageUrl":"crown","backgroundUrl1":"bg1",
"developer":"Dev","publisher":"Pub","productType":"GAME"}}}});
"logoUrl":"logo","developer":"Dev","publisher":"Pub","productType":"GAME"}}}});
let g = read_entitlement(&e).unwrap();
assert_eq!(g.entitlement_id, "ent-1");
assert_eq!(g.art, "crown");
assert_eq!(g.hero, "bg1");
assert_eq!(g.logo, "logo");
assert_eq!(g.to_json()["logo"], "logo");
assert_eq!(g.product_type, "GAME");
assert!(!g.is_dlc());
let untitled = read_entitlement(&json!({"product":{"id":"p","type":"DLC","baseProductId":"q"}})).unwrap();
assert_eq!(untitled.title, "Unknown");
assert_eq!(untitled.entitlement_id, "");
assert_eq!(untitled.parent_product_id, "q");
assert_eq!(untitled.logo, "");
assert!(untitled.is_dlc());
assert!(read_entitlement(&json!({"id":"x"})).is_none());
assert!(read_entitlement(&json!({"id":"x","product":{"id":""}})).is_none());
+5 -1
View File
@@ -71,6 +71,7 @@ pub fn details_of(entry: &Value) -> Value {
"releaseDate": release,
"tall": image(entry, &["DieselGameBoxTall"]),
"wide": image(entry, &["DieselGameBox", "Thumbnail"]),
"logo": image(entry, &["DieselGameBoxLogo"]),
"canRunOffline": offline,
"dlc": main_game.is_some_and(|m| !m.is_null()),
"mainGameId": main_game.map(|m| net::str_of(m, "id")).unwrap_or(""),
@@ -102,7 +103,8 @@ mod tests {
"keyImages": [
{"type": "Thumbnail", "url": "thumb"},
{"type": "DieselGameBox", "url": ""},
{"type": "DieselGameBoxTall", "url": "tall"}
{"type": "DieselGameBoxTall", "url": "tall"},
{"type": "DieselGameBoxLogo", "url": "logo"}
],
"customAttributes": {"CanRunOffline": {"value": "FALSE"}, "publisherName": {"value": " Pub "}},
"seller": {"name": "Seller"},
@@ -113,6 +115,7 @@ mod tests {
assert_eq!(d["title"], "Game");
assert_eq!(d["tall"], "tall");
assert_eq!(d["wide"], "thumb");
assert_eq!(d["logo"], "logo");
assert_eq!(d["publisher"], "Pub");
assert_eq!(d["canRunOffline"], false);
assert_eq!(d["releaseDate"], "2020-01-01T00:00:00.000Z");
@@ -123,6 +126,7 @@ mod tests {
assert_eq!(dlc["publisher"], "S");
assert_eq!(dlc["releaseDate"], "v");
assert_eq!(dlc["canRunOffline"], true);
assert_eq!(dlc["logo"], "");
assert_eq!(pick_entry(&json!({"x": {"title": "Only"}}), "y").unwrap()["title"], "Only");
}
}
@@ -34,6 +34,17 @@ class StoreGameSidecarTest {
assertEquals("ns", back.extra["namespace"])
}
@Test fun theStoreLogoRoundTripsAndOlderFilesHaveNone() {
val withLogo = sample.copy(logo = "https://x/logo.png")
val back = StoreGameSidecar.parse(withLogo.toJson().toString())!!
assertEquals("https://x/logo.png", back.logo)
assertEquals("https://x/logo.png", back.copy(state = StoreGameSidecar.STATE_UPDATING).logo)
assertFalse(sample.toJson().has("logo"))
assertNull(StoreGameSidecar.parse(sample.toJson().toString())!!.logo)
assertNull(StoreGameSidecar.parse("""{"store":"gog","id":"1","title":"T","exe":"game.exe","cover":"c","hero":"h"}""")!!.logo)
assertNull(StoreGameSidecar.parse("""{"store":"gog","id":"1","title":"T","exe":"game.exe","logo":""}""")!!.logo)
}
@Test fun defaultsWhenFieldsAreAbsent() {
val back = StoreGameSidecar.parse("""{"store":"gog","id":"1","title":"T","exe":"bin\\game.exe","addToSteam":false}""")!!
assertEquals("bin/game.exe", back.exe)
@@ -0,0 +1,88 @@
package com.droiddeck.launcher.stores
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Color
import com.droiddeck.launcher.stores.amazon.AmazonLibrary
import com.droiddeck.launcher.stores.gog.GogBackend
import org.json.JSONObject
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.io.ByteArrayOutputStream
import java.io.File
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class StoreLogoTest {
@get:Rule val tmp = TemporaryFolder()
private fun encoded(format: Bitmap.CompressFormat): ByteArray {
val bmp = Bitmap.createBitmap(8, 4, Bitmap.Config.ARGB_8888)
bmp.eraseColor(Color.TRANSPARENT)
bmp.setPixel(1, 1, Color.RED)
return ByteArrayOutputStream().also { bmp.compress(format, 100, it) }.toByteArray()
}
@Test fun aPngLogoIsKeptAsItCameWithItsTransparency() {
val png = encoded(Bitmap.CompressFormat.PNG)
val dst = File(tmp.root, StoreArt.LOGO)
assertTrue(StoreArt.writeLogo(png, dst))
assertArrayEquals(png, dst.readBytes())
val back = BitmapFactory.decodeFile(dst.path)
assertEquals(0, Color.alpha(back.getPixel(0, 0)))
assertEquals(Color.RED, back.getPixel(1, 1))
assertFalse(File(dst.path + ".tmp").exists())
}
@Test fun anotherImageFormatIsStoredAsPng() {
val dst = File(tmp.root, StoreArt.LOGO)
assertTrue(StoreArt.writeLogo(encoded(Bitmap.CompressFormat.JPEG), dst))
val head = dst.readBytes().take(4)
assertEquals(listOf(0x89.toByte(), 'P'.code.toByte(), 'N'.code.toByte(), 'G'.code.toByte()), head)
assertEquals(8, BitmapFactory.decodeFile(dst.path).width)
}
@Test fun whatIsNotAnImageIsRefusedAndAnExistingLogoStays() {
val dst = File(tmp.root, StoreArt.LOGO)
assertFalse(StoreArt.writeLogo("<html>not found</html>".toByteArray(), dst))
assertFalse(dst.exists())
dst.writeBytes(byteArrayOf(1, 2, 3))
assertTrue(StoreArt.writeLogo(encoded(Bitmap.CompressFormat.PNG), dst))
assertArrayEquals(byteArrayOf(1, 2, 3), dst.readBytes())
assertTrue(StoreArt.fetchLogo(tmp.root, "https://invalid.example/logo.png"))
}
@Test fun gogTakesTheV2LogoLinkOnly() {
val v2 = JSONObject("""{"_links":{"logo":{"href":"https://images.gog-statics.com/abc_logo.png"},"galaxyBackgroundImage":{"href":"https://x/bg.jpg"}}}""")
assertEquals("https://images.gog-statics.com/abc_logo.png", GogBackend.logoOf(v2))
assertEquals("https://images.gog-statics.com/rel.png", GogBackend.logoOf(JSONObject("""{"_links":{"logo":{"href":"//images.gog-statics.com/rel.png"}}}""")))
assertNull(GogBackend.logoOf(JSONObject("""{"_links":{}}""")))
assertNull(GogBackend.logoOf(JSONObject("""{"images":{"logo2x":"https://x/glx_logo_2x.jpg"}}""")))
assertNull(GogBackend.logoOf(JSONObject("""{"_links":{"logo":{"href":"http://x/logo.png"}}}""")))
}
@Test fun amazonKeepsTheLogoFromTheLibraryAndItsCache() {
val app = RuntimeEnvironment.getApplication()
val fresh = AmazonLibrary.gameFrom(JSONObject().put("productId", "p").put("title", "Game").put("logo", "https://m/logo.png"), null)
assertEquals("https://m/logo.png", fresh.logoUrl)
assertEquals("https://m/logo.png", AmazonLibrary.toCatalogItem(app, fresh).logoUrl)
assertEquals(listOf(fresh), AmazonLibrary.fromCacheJson(AmazonLibrary.toCacheJson(listOf(fresh))))
val kept = AmazonLibrary.gameFrom(JSONObject().put("productId", "p").put("title", "Game"), fresh)
assertEquals("https://m/logo.png", kept.logoUrl)
val none = AmazonLibrary.gameFrom(JSONObject().put("productId", "q").put("title", "Other"), null)
assertNull(AmazonLibrary.toCatalogItem(app, none).logoUrl)
assertEquals("", AmazonLibrary.fromCacheJson("""[{"productId":"old","title":"Old"}]""").single().logoUrl)
}
}
@@ -83,6 +83,18 @@ class EpicLibraryTest {
assertEquals("Fig", EpicLibrary.gameFrom(record, null, null).title)
}
@Test fun theCatalogLogoIsKeptInTheCacheAndHandedToTheInstall() {
val record = JSONObject().put("appName", "Fig").put("namespace", "ns").put("catalogItemId", "cat")
val fresh = EpicLibrary.gameFrom(record, JSONObject().put("logo", "https://e/logo.png"), null)
assertEquals("https://e/logo.png", fresh.artLogo)
assertEquals("https://e/logo.png", EpicLibrary.gameFrom(record, JSONObject().put("title", "Fig"), fresh).artLogo)
assertEquals(listOf(fresh), EpicLibrary.fromCacheJson(EpicLibrary.toCacheJson(listOf(fresh))))
assertEquals("https://e/logo.png", EpicLibrary.toCatalogItem(app, fresh).logoUrl)
val plain = EpicGame("Old", "ns", "c", "Old")
assertFalse(JSONArray(EpicLibrary.toCacheJson(listOf(plain))).getJSONObject(0).has("artLogo"))
assertEquals(null, EpicLibrary.toCatalogItem(app, plain).logoUrl)
}
@Test fun signOutForgetsOnlyTheLibrary() {
EpicPrefs.get(app).edit().putString(EpicLibrary.KEY_CACHE, "[]").putLong(EpicLibrary.KEY_SYNCED_AT, 1L)
.putString("cloud_template_Fig", "{AppData}/x").commit()