From 8ceff5641ac7e97554940887f9bc92bf6c54ac30 Mon Sep 17 00:00:00 2001 From: ovurrsl Date: Sat, 17 Oct 2026 08:52:01 +0300 Subject: [PATCH] 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. --- .../com/droiddeck/launcher/stores/StoreArt.kt | 40 ++++++++- .../droiddeck/launcher/stores/StoreCatalog.kt | 1 + .../launcher/stores/StoreGameSidecar.kt | 6 +- .../launcher/stores/StoreInstalls.kt | 2 +- .../launcher/stores/amazon/AmazonInstaller.kt | 1 + .../launcher/stores/amazon/AmazonLibrary.kt | 9 +- .../launcher/stores/download/SavedQueue.kt | 2 + .../launcher/stores/epic/EpicInstaller.kt | 2 +- .../launcher/stores/epic/EpicLibrary.kt | 7 +- .../launcher/stores/gog/GogBackend.kt | 10 +++ .../main/rust/stores/src/amazon/library.rs | 8 +- app/src/main/rust/stores/src/epic/catalog.rs | 6 +- .../launcher/stores/StoreGameSidecarTest.kt | 11 +++ .../launcher/stores/StoreLogoTest.kt | 88 +++++++++++++++++++ .../launcher/stores/epic/EpicLibraryTest.kt | 12 +++ 15 files changed, 193 insertions(+), 12 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/StoreLogoTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreArt.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreArt.kt index 4b2b0b803..14415fd9c 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreArt.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreArt.kt @@ -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 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 049e2b926..0e1e1b093 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt @@ -21,6 +21,7 @@ data class CatalogItem( val sizeBytes: Long = 0L, val extra: Map = emptyMap(), val mature: Boolean = false, + val logoUrl: String? = null, ) { val isDiscounted: Boolean get() = discountPercent > 0 && originalPrice.isNotBlank() val key: String get() = "${store.id}:$id" diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreGameSidecar.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreGameSidecar.kt index 25850f747..aa42aa75e 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreGameSidecar.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreGameSidecar.kt @@ -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 = this.env, installVersion: String = this.installVersion, installedAt: Long = this.installedAt, cover: String? = this.cover, hero: String? = this.hero, extra: Map = 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 }, ) } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt index 9b3f7136d..65fdf6e92 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonInstaller.kt b/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonInstaller.kt index 7eff5fbbe..401f621ed 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonInstaller.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonInstaller.kt @@ -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 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 841fe9217..41f049979 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 @@ -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) { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/download/SavedQueue.kt b/app/src/main/java/com/droiddeck/launcher/stores/download/SavedQueue.kt index 0c9fd0ccb..31053ea16 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/download/SavedQueue.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/download/SavedQueue.kt @@ -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 }, ) } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicInstaller.kt b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicInstaller.kt index ee6a2181d..9eee0adb1 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicInstaller.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicInstaller.kt @@ -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) 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 be665881b..32a485bfb 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 @@ -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()), ) } 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 1003921c0..3a0f91580 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 @@ -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()), ) diff --git a/app/src/main/rust/stores/src/amazon/library.rs b/app/src/main/rust/stores/src/amazon/library.rs index ec9ba55f8..46360a907 100644 --- a/app/src/main/rust/stores/src/amazon/library.rs +++ b/app/src/main/rust/stores/src/amazon/library.rs @@ -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 { ], ) .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()); diff --git a/app/src/main/rust/stores/src/epic/catalog.rs b/app/src/main/rust/stores/src/epic/catalog.rs index 4589ce276..420d656e8 100644 --- a/app/src/main/rust/stores/src/epic/catalog.rs +++ b/app/src/main/rust/stores/src/epic/catalog.rs @@ -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"); } } diff --git a/app/src/test/java/com/droiddeck/launcher/stores/StoreGameSidecarTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/StoreGameSidecarTest.kt index 213cbf9a1..b7ad90b56 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/StoreGameSidecarTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/StoreGameSidecarTest.kt @@ -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) diff --git a/app/src/test/java/com/droiddeck/launcher/stores/StoreLogoTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/StoreLogoTest.kt new file mode 100644 index 000000000..71a6c1d8e --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/StoreLogoTest.kt @@ -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("not found".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) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/epic/EpicLibraryTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/epic/EpicLibraryTest.kt index 4eb819043..a3f156e1b 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/epic/EpicLibraryTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/epic/EpicLibraryTest.kt @@ -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()