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()