From 8ceff5641ac7e97554940887f9bc92bf6c54ac30 Mon Sep 17 00:00:00 2001 From: ovurrsl Date: Sat, 17 Oct 2026 08:52:01 +0300 Subject: [PATCH 01/11] 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() From be384ca2a2c5409db0e46f660c94524c0807092b Mon Sep 17 00:00:00 2001 From: ovurrsl Date: Sat, 17 Oct 2026 08:52:01 +0300 Subject: [PATCH 02/11] feat: games that already have a cover still get a logo and icon The automatic art fill no longer skips a game just because its folder has a cover. Store games and added games with their own cover now get a missing logo (the store's own logo first, then Steam, then SteamGridDB) and a missing icon from SteamGridDB. Existing, folder and chosen art is never replaced; a slot that stays empty is asked about again after a week, as before. --- .../launcher/frontend/AddedGameArt.kt | 110 +++++++++--- .../launcher/frontend/SteamGridDb.kt | 23 ++- .../launcher/frontend/AddedGameArtFillTest.kt | 163 ++++++++++++++++++ 3 files changed, 260 insertions(+), 36 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/frontend/AddedGameArtFillTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGameArt.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGameArt.kt index b3bdeb968..0c5ca7b98 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGameArt.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGameArt.kt @@ -5,6 +5,8 @@ import android.graphics.Bitmap import android.util.Log import com.droiddeck.launcher.files.PeIconExtractor import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.stores.StoreArt +import com.droiddeck.launcher.stores.StoreGameSidecar import org.json.JSONObject import java.io.File import java.net.HttpURLConnection @@ -228,41 +230,93 @@ object AddedGameArt { return if (tmp.renameTo(png)) png else null } - fun fetchMissing(context: Context, games: List): Boolean { + internal class Sources( + val storeLogo: (AddedGames.Game) -> Boolean, + val steamSearch: (String) -> Int?, + val steam: (String, File, Set) -> Boolean, + val sgdbGame: (String, Int?, String) -> Int?, + val sgdb: (String, Int, File, Set) -> Boolean, + ) + + private val network = Sources( + storeLogo = ::storeLogo, + steamSearch = { name -> search(name)?.toIntOrNull() }, + steam = ::download, + sgdbGame = SteamGridDb::gameId, + sgdb = SteamGridDb::download, + ) + + private val EXTRAS = setOf(Slot.LOGO, Slot.ICON) + private val STEAM_SLOTS = setOf(Slot.COVER, Slot.WIDE, Slot.BACKGROUND, Slot.LOGO) + internal const val EXTRAS_TRIED = "extras-tried" + + internal fun missing(context: Context, game: AddedGames.Game): Set { + val own = local(game) + val cache = cacheDir(context, game) + fun has(slot: Slot, mine: File?, cached: String) = chosen(cache, slot) != null || mine != null || File(cache, cached).isFile + return Slot.values().filterNot { slot -> + when (slot) { + Slot.COVER -> has(slot, own.portrait, "p.jpg") + Slot.WIDE -> has(slot, own.header, "header.jpg") + Slot.BACKGROUND -> has(slot, own.hero, "hero.jpg") + Slot.LOGO -> has(slot, own.logo, "logo.png") + Slot.ICON -> has(slot, own.icon, "icon-sgdb.png") + } + }.toSet() + } + + private fun storeLogo(game: AddedGames.Game): Boolean { + val url = StoreGameSidecar.read(game.folder)?.logo ?: return false + return StoreArt.fetchLogo(game.folder, url) + } + + fun fetchMissing(context: Context, games: List): Boolean = + fetchMissing(context, games, SteamGridDb.key(context), network) + + internal fun fetchMissing(context: Context, games: List, sgdbKey: String, sources: Sources): Boolean { if (!SessionPrefs.addedGamesArt(context)) return false - val sgdbKey = SteamGridDb.key(context) val keyChangedAt = SteamGridDb.keyChangedAt(context) var sgdbDown = false var changed = false for (game in games) { - if (local(game).portrait != null) continue + val start = missing(context, game) + val full = Slot.COVER in start + val allowed = if (full) Slot.values().toSet() else EXTRAS + var need = start intersect allowed + if (need.isEmpty() || (need == setOf(Slot.ICON) && sgdbKey.isEmpty())) continue val cache = cacheDir(context, game) - if (File(cache, "p.jpg").isFile) continue + val tried = File(cache, EXTRAS_TRIED) + if (!full && tried.isFile && tried.lastModified() >= keyChangedAt && System.currentTimeMillis() - tried.lastModified() < RETRY_AFTER_MS) continue cache.mkdirs() - val match = steamMatch(context, game) { name -> - runCatching { search(name) }.onFailure { Log.w(TAG, "${game.name}: store search failed (${it.message})") }.getOrNull()?.toIntOrNull() + if (Slot.LOGO in need && game.source != Library.ADDED && runCatching { sources.storeLogo(game) }.getOrDefault(false)) { + changed = true + need = need - Slot.LOGO } - val appId = match.appId?.takeIf { match.certainty == SteamMatch.Certainty.FILES } - val id = match.appId?.toString() - if (id != null && download(id, cache)) changed = true - if (File(cache, "p.jpg").isFile || sgdbKey.isEmpty() || sgdbDown) continue - val stored: String? = try { - remembered(File(cache, "sgdb-id"), keyChangedAt) { - SteamGridDb.gameId(sgdbKey, appId ?: id?.toIntOrNull(), game.name)?.toString() + val match by lazy { + steamMatch(context, game) { name -> + runCatching { sources.steamSearch(name) }.onFailure { Log.w(TAG, "${game.name}: store search failed (${it.message})") }.getOrNull() } - } catch (e: Exception) { - Log.w(TAG, "${game.name}: SteamGridDB lookup failed (${e.javaClass.simpleName}); not asking again this pass") - sgdbDown = true - null } - val gridId = stored?.toIntOrNull() ?: continue - val grabbed = try { - SteamGridDb.download(sgdbKey, gridId, cache) - } catch (e: Exception) { - sgdbDown = true - false + val steamWant = need intersect STEAM_SLOTS + if (steamWant.isNotEmpty()) { + val id = match.appId?.toString() + if (id != null && sources.steam(id, cache, steamWant)) changed = true + need = missing(context, game) intersect allowed } - if (grabbed) changed = true + var asked = true + if (need.isNotEmpty() && sgdbKey.isNotEmpty()) { + if (sgdbDown) asked = false else try { + val gridId = remembered(File(cache, "sgdb-id"), keyChangedAt) { + sources.sgdbGame(sgdbKey, match.appId, game.name)?.toString() + }?.toIntOrNull() + if (gridId != null && sources.sgdb(sgdbKey, gridId, cache, need)) changed = true + } catch (e: Exception) { + Log.w(TAG, "${game.name}: SteamGridDB failed (${e.javaClass.simpleName}); not asking again this pass") + sgdbDown = true + asked = false + } + } + if ((missing(context, game) intersect EXTRAS).isEmpty()) tried.delete() else if (asked) tried.writeText("") } return changed } @@ -314,11 +368,13 @@ object AddedGameArt { return pickStoreResult(name, (0 until items.length()).map { items.getJSONObject(it) }.map { it.optString("id") to it.optString("name") }) } - private fun download(id: String, cache: File): Boolean { + private fun download(id: String, cache: File, slots: Set): Boolean { var got = false - for ((remote, localName) in listOf( - "library_600x900.jpg" to "p.jpg", "header.jpg" to "header.jpg", "library_hero.jpg" to "hero.jpg", "logo.png" to "logo.png", + for ((slot, remote, localName) in listOf( + Triple(Slot.COVER, "library_600x900.jpg", "p.jpg"), Triple(Slot.WIDE, "header.jpg", "header.jpg"), + Triple(Slot.BACKGROUND, "library_hero.jpg", "hero.jpg"), Triple(Slot.LOGO, "logo.png", "logo.png"), )) { + if (slot !in slots) continue val dst = File(cache, localName) if (dst.isFile) continue val bytes = get("$CDN$id/$remote") ?: steamAssetUrl(id, remote)?.let { get(it) } ?: continue diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/SteamGridDb.kt b/app/src/main/java/com/droiddeck/launcher/frontend/SteamGridDb.kt index dec6c24cf..2eaa89410 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/SteamGridDb.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/SteamGridDb.kt @@ -84,18 +84,23 @@ object SteamGridDb { .filter { it.second > 0 && it.first > 0 } .maxByOrNull { it.second }?.first - fun download(key: String, id: Int, cache: File): Boolean { + internal fun cacheName(slot: AddedGameArt.Slot): String = + when (slot) { + AddedGameArt.Slot.COVER -> "p.jpg" + AddedGameArt.Slot.WIDE -> "header.jpg" + AddedGameArt.Slot.BACKGROUND -> "hero.jpg" + AddedGameArt.Slot.LOGO -> "logo.png" + AddedGameArt.Slot.ICON -> "icon-sgdb.png" + } + + fun download(key: String, id: Int, cache: File, slots: Set = AddedGameArt.Slot.values().toSet()): Boolean { var got = false - for ((path, local) in listOf( - "grids/game/$id?dimensions=600x900&$QUERY" to "p.jpg", - "grids/game/$id?dimensions=460x215,920x430&$QUERY" to "header.jpg", - "heroes/game/$id?$QUERY" to "hero.jpg", - "logos/game/$id?$QUERY" to "logo.png", - "icons/game/$id?$QUERY" to "icon-sgdb.png", - )) { + for (slot in AddedGameArt.Slot.values()) { + if (slot !in slots) continue + val local = cacheName(slot) val dst = File(cache, local) if (dst.isFile) continue - val items = json(key, "$API/$path")?.optJSONArray("data") ?: continue + val items = json(key, "$API/${path(id, slot)}")?.optJSONArray("data") ?: continue val url = best(items, local.endsWith(".png")) ?: continue val bytes = AddedGameArt.get(url) ?: continue val tmp = File(cache, "$local.tmp") diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/AddedGameArtFillTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGameArtFillTest.kt new file mode 100644 index 000000000..473c43922 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGameArtFillTest.kt @@ -0,0 +1,163 @@ +package com.droiddeck.launcher.frontend + +import com.droiddeck.launcher.frontend.AddedGameArt.Slot +import com.droiddeck.launcher.session.SessionPrefs +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +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 java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class AddedGameArtFillTest { + private val app get() = RuntimeEnvironment.getApplication() + @get:Rule val tmp = TemporaryFolder() + + private val coverBytes = byteArrayOf(9, 9, 9) + + private fun game(name: String, appId: Long, cover: Boolean = true, source: String = Library.ADDED): AddedGames.Game { + val dir = tmp.newFolder(name) + File(dir, "steam_appid.txt").writeText("504230") + if (cover) File(dir, "cover.jpg").writeBytes(coverBytes) + val exe = File(dir, "$name.exe").apply { writeText("x") } + return AddedGames.Game(dir, name, exe, "/g/$name.exe", "/g", appId, appId, emptyList(), source = source) + } + + private class Calls { + val steam = ArrayList>() + val sgdb = ArrayList>() + var lookups = 0 + var store = 0 + } + + private val steamNames = mapOf(Slot.COVER to "p.jpg", Slot.WIDE to "header.jpg", Slot.BACKGROUND to "hero.jpg", Slot.LOGO to "logo.png") + + private fun sources( + calls: Calls, steamHas: Set = emptySet(), sgdbHas: Set = emptySet(), + storeHas: Boolean = false, sgdbFails: Boolean = false, + ) = AddedGameArt.Sources( + storeLogo = { g -> + calls.store++ + if (storeHas) File(g.folder, "logo.png").writeBytes(byteArrayOf(4)) + storeHas + }, + steamSearch = { fail("searched although the game names its appid"); null }, + steam = { id, cache, slots -> + assertEquals("504230", id) + calls.steam += slots + (slots intersect steamHas).onEach { File(cache, steamNames.getValue(it)).writeBytes(byteArrayOf(1)) }.isNotEmpty() + }, + sgdbGame = { _, steamId, _ -> + calls.lookups++ + assertEquals(504230, steamId) + 77 + }, + sgdb = { _, id, cache, slots -> + assertEquals(77, id) + if (sgdbFails) throw SteamGridDb.Failed(SteamGridDb.Status.OFFLINE) + calls.sgdb += slots + (slots intersect sgdbHas).onEach { File(cache, SteamGridDb.cacheName(it)).writeBytes(byteArrayOf(2)) }.isNotEmpty() + }, + ) + + @Test fun aGameWithItsOwnCoverStillGetsItsLogoAndIcon() { + val g = game("Celeste", 0x81000001L) + val calls = Calls() + assertTrue(AddedGameArt.fetchMissing(app, listOf(g), "key", sources(calls, steamHas = setOf(Slot.LOGO), sgdbHas = setOf(Slot.ICON)))) + assertEquals(listOf(setOf(Slot.LOGO)), calls.steam) + assertEquals(listOf(setOf(Slot.ICON)), calls.sgdb) + assertEquals(0, calls.store) + val cache = AddedGameArt.cacheDir(app, g) + assertFalse(File(cache, "p.jpg").exists()) + assertArrayEquals(coverBytes, File(g.folder, "cover.jpg").readBytes()) + val art = AddedGameArt.resolve(app, g) + assertEquals(File(g.folder, "cover.jpg"), art.portrait) + assertEquals(File(cache, "logo.png"), art.logo) + assertEquals(File(cache, "icon-sgdb.png"), art.icon) + assertFalse(File(cache, AddedGameArt.EXTRAS_TRIED).exists()) + } + + @Test fun filledOrChosenSlotsAreNeverAskedFor() { + val g = game("Hades", 0x81000002L) + File(g.folder, "logo.png").writeBytes(byteArrayOf(5)) + val picked = File(tmp.root, "mine.png").apply { writeBytes(byteArrayOf(6)) } + assertTrue(AddedGameArt.store(AddedGameArt.cacheDir(app, g), Slot.ICON, AddedGameArt.Option(AddedGameArt.FILE, picked.path, picked.path))) + val calls = Calls() + assertFalse(AddedGameArt.fetchMissing(app, listOf(g), "key", sources(calls, steamHas = Slot.values().toSet(), sgdbHas = Slot.values().toSet()))) + assertTrue(calls.steam.isEmpty() && calls.sgdb.isEmpty() && calls.lookups == 0) + assertArrayEquals(byteArrayOf(5), File(g.folder, "logo.png").readBytes()) + } + + @Test fun withoutAKeyAMissingIconAloneAsksNothing() { + val g = game("Inside", 0x81000003L) + File(g.folder, "logo.png").writeBytes(byteArrayOf(5)) + val calls = Calls() + assertFalse(AddedGameArt.fetchMissing(app, listOf(g), "", sources(calls))) + assertTrue(calls.steam.isEmpty() && calls.sgdb.isEmpty() && calls.lookups == 0) + assertFalse(AddedGameArt.cacheDir(app, g).exists()) + } + + @Test fun aMissIsAskedAgainOnlyAfterAWeek() { + val g = game("Limbo", 0x81000004L) + val calls = Calls() + assertFalse(AddedGameArt.fetchMissing(app, listOf(g), "key", sources(calls))) + assertEquals(listOf(setOf(Slot.LOGO)), calls.steam) + assertEquals(listOf(setOf(Slot.LOGO, Slot.ICON)), calls.sgdb) + AddedGameArt.fetchMissing(app, listOf(g), "key", sources(calls)) + assertEquals(1, calls.steam.size) + assertEquals(1, calls.sgdb.size) + val tried = File(AddedGameArt.cacheDir(app, g), AddedGameArt.EXTRAS_TRIED) + assertTrue(tried.setLastModified(System.currentTimeMillis() - 8L * 24 * 3600 * 1000)) + assertTrue(AddedGameArt.fetchMissing(app, listOf(g), "key", sources(calls, steamHas = setOf(Slot.LOGO), sgdbHas = setOf(Slot.ICON)))) + assertEquals(2, calls.steam.size) + assertEquals(setOf(Slot.ICON), calls.sgdb.last()) + assertFalse(tried.exists()) + } + + @Test fun theStoresOwnLogoComesFirst() { + val g = game("Metalstorm", 0x81000005L, source = "gog") + val calls = Calls() + assertTrue(AddedGameArt.fetchMissing(app, listOf(g), "key", sources(calls, storeHas = true, steamHas = setOf(Slot.LOGO), sgdbHas = setOf(Slot.ICON)))) + assertEquals(1, calls.store) + assertTrue(calls.steam.isEmpty()) + assertEquals(listOf(setOf(Slot.ICON)), calls.sgdb) + assertEquals(File(g.folder, "logo.png"), AddedGameArt.resolve(app, g).logo) + } + + @Test fun aGameWithoutACoverStillGetsEverySlot() { + val g = game("Tunic", 0x81000006L, cover = false) + val calls = Calls() + assertTrue(AddedGameArt.fetchMissing(app, listOf(g), "key", + sources(calls, steamHas = setOf(Slot.COVER, Slot.WIDE, Slot.BACKGROUND), sgdbHas = setOf(Slot.LOGO, Slot.ICON)))) + assertEquals(listOf(setOf(Slot.COVER, Slot.WIDE, Slot.BACKGROUND, Slot.LOGO)), calls.steam) + assertEquals(listOf(setOf(Slot.LOGO, Slot.ICON)), calls.sgdb) + assertTrue(AddedGameArt.missing(app, g).isEmpty()) + } + + @Test fun aSteamGridDbFailureIsNotRememberedAndStopsThePass() { + val first = game("Gris", 0x81000007L) + val second = game("Hollow", 0x81000008L) + val calls = Calls() + AddedGameArt.fetchMissing(app, listOf(first, second), "key", sources(calls, sgdbFails = true)) + assertEquals(2, calls.steam.size) + assertEquals(1, calls.lookups) + assertFalse(File(AddedGameArt.cacheDir(app, first), AddedGameArt.EXTRAS_TRIED).exists()) + assertFalse(File(AddedGameArt.cacheDir(app, second), AddedGameArt.EXTRAS_TRIED).exists()) + } + + @Test fun nothingIsFetchedWhenArtIsOff() { + SessionPrefs.setAddedGamesArt(app, false) + val calls = Calls() + assertFalse(AddedGameArt.fetchMissing(app, listOf(game("Off", 0x81000009L)), "key", sources(calls))) + assertTrue(calls.steam.isEmpty()) + } +} From 8faf88de85baa9ba95752b422b98e8d6cd4bccc6 Mon Sep 17 00:00:00 2001 From: ovurrsl Date: Sat, 17 Oct 2026 08:52:01 +0300 Subject: [PATCH 03/11] feat: the game page shows the game's logo The hero on the Games page draws the game's logo instead of the plain title when there is one: Steam's cached logo.png or a chosen logo for Steam games, the resolved logo for added and store games. The title text stays as the fallback and as the logo's description for screen readers. --- .../com/droiddeck/launcher/MainActivity.kt | 2 +- .../droiddeck/launcher/frontend/Library.kt | 8 +- .../launcher/frontend/LibraryCache.kt | 3 +- .../launcher/frontend/SteamGameArt.kt | 4 +- .../droiddeck/launcher/ui/FrontEndGames.kt | 18 ++++- .../launcher/frontend/GameLogoTest.kt | 80 +++++++++++++++++++ 6 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/frontend/GameLogoTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index ec5ff76c4..d083baa56 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -1963,7 +1963,7 @@ class MainActivity : ComponentActivity() { steamGamesX64 = scannedX64 steamGames = all.map { g -> val newer = current[g.appId] - if (newer != null && g.library != Library.ADDED && steamArtRefreshes[g.appId] != artRefreshesAtStart[g.appId]) g.withArt(newer.art, newer.hero, newer.icon) else g + if (newer != null && g.library != Library.ADDED && steamArtRefreshes[g.appId] != artRefreshesAtStart[g.appId]) g.withArt(newer.art, newer.hero, newer.icon, newer.logo) else g } shortcutLibraryScanning = false handleGameLink(all) diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt index 1242d9add..91691cb1e 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -16,9 +16,10 @@ object Library { val icon: File? = null, val source: String = SOURCE_STEAM, val storeId: String? = null, + val logo: File? = null, ) { - fun withArt(art: File?, hero: File?, icon: File?) = - SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId) + fun withArt(art: File?, hero: File?, icon: File?, logo: File? = this.logo) = + SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId, logo) val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId) val profileKey: String get() = if (library == ADDED) java.lang.Integer.toUnsignedString(appId) else appId.toString() @@ -68,6 +69,7 @@ object Library { ) private val STEAM_CAPSULES = listOf("library_capsule.jpg", "library_600x900.jpg") private val STEAM_HEROES = listOf("library_hero.jpg") + internal val STEAM_LOGOS = listOf("logo.png") private val STEAM_ICON = Regex("[a-fA-F0-9]{40}\\.(jpg|png)") private val NAME = Regex("^\\s*\"name\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE) private val STATE = Regex("^\\s*\"StateFlags\"\\s*\"(\\d+)\"", RegexOption.MULTILINE) @@ -131,6 +133,7 @@ object Library { protonPrefix = protonPrefix(context, appId.toLong(), library, roots), icon = steamCacheImage(cache, appId, listOf("icon.jpg", "icon.png")) ?: File(cache, appId.toString()).listFiles()?.firstOrNull { it.isFile && STEAM_ICON.matches(it.name) }, + logo = steamCacheImage(cache, appId, STEAM_LOGOS), )) } } @@ -203,6 +206,7 @@ object Library { protonPrefix = protonPrefix(context, g.steamAppId?.toLong() ?: g.appId, libraries = roots), icon = art.icon?.takeIf { it.extension.lowercase() != "ico" }, source = g.source, storeId = g.storeId, + logo = art.logo, ) } diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/LibraryCache.kt b/app/src/main/java/com/droiddeck/launcher/frontend/LibraryCache.kt index 3d03b2307..cd30e400c 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/LibraryCache.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/LibraryCache.kt @@ -27,6 +27,7 @@ object LibraryCache { icon = o.file("icon"), source = o.optString("source").ifEmpty { if (o.getString("library") == Library.ADDED) Library.ADDED else Library.SOURCE_STEAM }, storeId = o.optString("storeId").ifEmpty { null }, + logo = o.file("logo"), ) } to x64 }.getOrElse { e -> Log.w(TAG, "unreadable, ignored: ${e.message}"); Pair(emptyList(), false) } @@ -44,7 +45,7 @@ object LibraryCache { for (g in games) array.put( JSONObject().put("appId", g.appId).put("name", g.name).put("library", g.library).put("gameId", g.gameId) .put("lastPlayed", g.lastPlayed).putFile("art", g.art).putFile("hero", g.hero) - .putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix).putFile("icon", g.icon) + .putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix).putFile("icon", g.icon).putFile("logo", g.logo) .put("source", g.source).apply { g.storeId?.let { put("storeId", it) } }, ) val f = file(context) diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/SteamGameArt.kt b/app/src/main/java/com/droiddeck/launcher/frontend/SteamGameArt.kt index cb4f49f0f..25a3cb8fb 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/SteamGameArt.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/SteamGameArt.kt @@ -22,10 +22,12 @@ object SteamGameArt { val cover = chosenFile(context, game.appId, AddedGameArt.Slot.COVER) val hero = chosenFile(context, game.appId, AddedGameArt.Slot.BACKGROUND) val icon = chosenFile(context, game.appId, AddedGameArt.Slot.ICON) - if (cover == null && hero == null && icon == null) return game + val logo = chosenFile(context, game.appId, AddedGameArt.Slot.LOGO) + if (cover == null && hero == null && icon == null && logo == null) return game return Library.SteamGame( game.appId, game.name, cover ?: game.art, game.library, game.gameId, hero ?: game.hero, game.lastPlayed, game.gameFiles, game.protonPrefix, icon ?: game.icon, game.source, game.storeId, + logo ?: game.logo, ) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt index 679348878..85dbc8694 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -324,11 +325,24 @@ private fun ColumnScope.GameHeroCopy(g: Library.SteamGame, titleSize: androidx.c maxLines = 1, overflow = TextOverflow.Ellipsis, ) } - Text( + GameTitle(g, titleSize) + Spacer(Modifier.height(8.dp)) +} + +@Composable +private fun GameTitle(g: Library.SteamGame, titleSize: androidx.compose.ui.unit.TextUnit) { + val logo = g.logo + var failed by remember(logo) { androidx.compose.runtime.mutableStateOf(false) } + if (logo != null && !failed) { + AsyncImage( + model = logo, contentDescription = g.name, contentScale = ContentScale.Fit, alignment = Alignment.CenterStart, + onError = { failed = true }, + modifier = Modifier.padding(vertical = 4.dp).height((titleSize.value * 2.2f).dp).widthIn(max = (titleSize.value * 9f).dp).testTag("game-logo"), + ) + } else Text( g.name, fontSize = titleSize, lineHeight = titleSize * 1.15f, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, ) - Spacer(Modifier.height(8.dp)) } @Composable diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/GameLogoTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/GameLogoTest.kt new file mode 100644 index 000000000..5a577f5b3 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/frontend/GameLogoTest.kt @@ -0,0 +1,80 @@ +package com.droiddeck.launcher.frontend + +import com.droiddeck.launcher.session.SessionPrefs +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +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 java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class GameLogoTest { + private val app get() = RuntimeEnvironment.getApplication() + @get:Rule val tmp = TemporaryFolder() + + @Before fun noGamesFolders() { + AddedGamesPublisher.now = true + SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") + SessionPrefs.setAddedGamesDirs(app, emptyList()) + } + + private fun image(name: String) = File(tmp.root, name).apply { parentFile?.mkdirs(); writeBytes(ByteArray(8) { 1 }) } + + @Test fun steamsCachedLogoIsFoundInTheAppFolderOrAHashedOne() { + val cache = tmp.newFolder("librarycache") + val logo = File(cache, "620/3f1c/logo.png").apply { parentFile?.mkdirs(); writeBytes(ByteArray(4) { 1 }) } + assertEquals(logo, Library.steamCacheImage(cache, 620, Library.STEAM_LOGOS)) + File(cache, "730").mkdirs() + File(cache, "730/logo.png").writeBytes(ByteArray(0)) + assertNull(Library.steamCacheImage(cache, 730, Library.STEAM_LOGOS)) + } + + @Test fun aChosenLogoWinsOverSteamsAndKeepsTheRest() { + val stock = image("logo.png") + val game = Library.SteamGame(620, "Portal 2", null, "Steam library", logo = stock) + assertTrue(SteamGameArt.withOverrides(app, game) === game) + val picked = image("mine.png") + assertTrue(SteamGameArt.choose(app, game, AddedGameArt.Slot.LOGO, AddedGameArt.Option(AddedGameArt.FILE, picked.path, picked.path))) + val shown = SteamGameArt.withOverrides(app, game) + assertTrue(shown.logo!!.name.startsWith("chosen-logo")) + assertNull(shown.art) + SteamGameArt.reset(app, game, AddedGameArt.Slot.LOGO) + assertEquals(stock, SteamGameArt.withOverrides(app, game).logo) + } + + @Test fun anAddedGameShowsItsFolderLogoUnlessOneWasChosen() { + val dir = tmp.newFolder("Celeste") + val own = File(dir, "logo.png").apply { writeBytes(ByteArray(4) { 1 }) } + val exe = File(dir, "Celeste.exe").apply { writeText("x") } + val g = AddedGames.Game(dir, "Celeste", exe, "/g/Celeste.exe", "/g", 0x82000001L, 0x82000001L, emptyList(), source = "gog") + assertEquals(own, Library.addedGame(app, g, emptyList()).logo) + val picked = image("pick.png") + assertTrue(AddedGameArt.choose(app, g, AddedGameArt.Slot.LOGO, AddedGameArt.Option(AddedGameArt.FILE, picked.path, picked.path))) + assertTrue(Library.addedGame(app, g, emptyList()).logo!!.name.startsWith("chosen-logo")) + val bare = AddedGames.Game(tmp.newFolder("Bare"), "Bare", exe, "/g/Bare.exe", "/g", 0x82000002L, 0x82000002L, emptyList()) + assertNull(Library.addedGame(app, bare, emptyList()).logo) + } + + @Test fun theLibraryCacheKeepsTheLogoAndReadsOlderEntries() { + val logo = image("logo.png") + val game = Library.SteamGame(620, "Portal 2", null, "Steam library", logo = logo) + LibraryCache.save(app, listOf(game, Library.SteamGame(730, "CS2", null, "Steam library"))) + val back = LibraryCache.load(app) + assertEquals(logo, back[0].logo) + assertNull(back[1].logo) + assertEquals(logo, game.withArt(null, null, null).logo) + val file = File(app.filesDir, "library-cache.json") + file.writeText(JSONArray().put(JSONObject().put("appId", 1).put("name", "Old").put("library", "x").put("gameId", 1L)).toString()) + assertNull(LibraryCache.load(app).single().logo) + } +} From 240b7fcce6688b7b457bc2ca6a38ab8887d359b2 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 22 Oct 2026 09:02:37 -0400 Subject: [PATCH 04/11] feat: page the session out to zram before suspending it, and zram is always on where supported --- .../com/droiddeck/launcher/MainActivity.kt | 8 --- .../droiddeck/launcher/core/ZramSupport.kt | 16 ----- .../launcher/session/SessionPrefs.kt | 10 ---- .../launcher/session/SessionService.kt | 18 +++++- .../session/SessionSuspendController.kt | 60 ++++++++++++++++++- .../launcher/ui/ModeSettingsDialog.kt | 9 --- app/src/main/res/values-ar/strings.xml | 3 - app/src/main/res/values-b+es+419/strings.xml | 3 - app/src/main/res/values-bg/strings.xml | 3 - app/src/main/res/values-cs/strings.xml | 3 - app/src/main/res/values-da/strings.xml | 3 - app/src/main/res/values-de/strings.xml | 3 - app/src/main/res/values-el/strings.xml | 3 - app/src/main/res/values-es/strings.xml | 3 - app/src/main/res/values-fi/strings.xml | 3 - app/src/main/res/values-fr/strings.xml | 3 - app/src/main/res/values-hi/strings.xml | 3 - app/src/main/res/values-hu/strings.xml | 3 - app/src/main/res/values-in/strings.xml | 3 - app/src/main/res/values-it/strings.xml | 3 - app/src/main/res/values-ja/strings.xml | 3 - app/src/main/res/values-ko/strings.xml | 3 - app/src/main/res/values-ms/strings.xml | 3 - app/src/main/res/values-nb/strings.xml | 3 - app/src/main/res/values-nl/strings.xml | 3 - app/src/main/res/values-pl/strings.xml | 3 - app/src/main/res/values-pt-rPT/strings.xml | 3 - app/src/main/res/values-pt/strings.xml | 3 - app/src/main/res/values-ro/strings.xml | 3 - app/src/main/res/values-ru/strings.xml | 3 - app/src/main/res/values-sv/strings.xml | 3 - app/src/main/res/values-th/strings.xml | 3 - app/src/main/res/values-tr/strings.xml | 3 - app/src/main/res/values-uk/strings.xml | 3 - app/src/main/res/values-vi/strings.xml | 3 - app/src/main/res/values-zh-rCN/strings.xml | 3 - app/src/main/res/values-zh-rHK/strings.xml | 3 - app/src/main/res/values-zh-rTW/strings.xml | 3 - app/src/main/res/values/strings.xml | 3 - .../launcher/core/ZramSupportTest.kt | 14 ----- .../session/SessionSuspendControllerTest.kt | 48 ++++++++++++++- .../overlay/usr/local/bin/droiddeck-session | 6 +- tools/linuxfs/preload/zram.c | 2 - 43 files changed, 122 insertions(+), 168 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 4e455d3c1..280c2dabd 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -392,8 +392,6 @@ class MainActivity : ComponentActivity() { private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO) private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL) private var steamDownloadsInBackground by mutableStateOf(false) - private var zramCompression by mutableStateOf(false) - private var zramSupported by mutableStateOf(null) private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO) private var backActionsInverted by mutableStateOf(false) private var gameStorage by mutableStateOf("") @@ -1616,8 +1614,6 @@ class MainActivity : ComponentActivity() { touchMode = touchMode, suspendPolicy = suspendPolicy, steamDownloadsInBackground = steamDownloadsInBackground, - zramCompression = if (mode == SessionService.MODE_STEAM) zramCompression else null, - zramSupported = zramSupported, pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this), pipAutoEnter = pipAutoEnter, unfoldedControls = if (mode == SessionService.MODE_STEAM && @@ -1685,7 +1681,6 @@ class MainActivity : ComponentActivity() { SessionPrefs.setSteamDownloadsInBackground(this, enabled) steamDownloadsInBackground = enabled }, - onZramCompression = { on -> SessionPrefs.setZramCompression(this, on); zramCompression = on }, onPipAutoEnter = { on -> SessionPrefs.setPipAutoEnter(this, on); pipAutoEnter = on }, onUnfoldedControls = { on -> SessionPrefs.setUnfoldedControls(this, on); unfoldedControls = on }, onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o }, @@ -1912,9 +1907,6 @@ class MainActivity : ComponentActivity() { storageDiagnostics = SessionPrefs.storageDiagnosticsEnabled(this) settingsMode = mode Thread({ - val zram = com.droiddeck.launcher.core.ZramSupport.supported() - val zramOn = SessionPrefs.zramCompression(this) - ui.post { zramSupported = zram; zramCompression = zramOn } drivers.refreshDrivers() val storage = GameStorage.options(this) val deckyInstalled = if (mode == SessionService.MODE_STEAM) DeckyManager.installed(this) else null diff --git a/app/src/main/java/com/droiddeck/launcher/core/ZramSupport.kt b/app/src/main/java/com/droiddeck/launcher/core/ZramSupport.kt index 51c3b2494..3befaac38 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/ZramSupport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/ZramSupport.kt @@ -1,26 +1,10 @@ package com.droiddeck.launcher.core -import android.app.ActivityManager -import android.content.Context -import android.os.Build import java.io.File object ZramSupport { - private const val GIB = 1L shl 30 - fun supported(): Boolean = kernelAtLeast(System.getProperty("os.version").orEmpty(), 5, 4) && swapTotalKb() > 0 - fun recommended(context: Context): Boolean { - if (!supported()) return false - val memory = ActivityManager.MemoryInfo() - context.getSystemService(ActivityManager::class.java).getMemoryInfo(memory) - val advertised = if (Build.VERSION.SDK_INT >= 34) memory.advertisedMem else 0L - return belowTwelveGb(advertised, memory.totalMem) - } - - fun belowTwelveGb(advertisedBytes: Long, totalBytes: Long): Boolean = - if (advertisedBytes > 0) advertisedBytes < 11 * GIB else totalBytes < 10 * GIB - fun kernelAtLeast(release: String, major: Int, minor: Int): Boolean { val parts = Regex("^(\\d+)\\.(\\d+)").find(release)?.groupValues ?: return false val ma = parts[1].toInt() diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index b548e56de..c988bc5ab 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -7,7 +7,6 @@ import com.droiddeck.launcher.R import com.droiddeck.launcher.core.CpuAllocationDefaults import com.droiddeck.launcher.core.GameEnvironment import com.droiddeck.launcher.core.TextureFiltering -import com.droiddeck.launcher.core.ZramSupport import com.droiddeck.launcher.gpu.Cardboard import com.droiddeck.launcher.gpu.ScreenEffects import org.json.JSONObject @@ -1034,15 +1033,6 @@ object SessionPrefs { }.apply() } - fun zramCompression(context: Context): Boolean { - val prefs = prefs(context) - return if (prefs.contains("zramCompression")) prefs.getBoolean("zramCompression", false) else ZramSupport.recommended(context) - } - - fun setZramCompression(context: Context, on: Boolean) { - prefs(context).edit().putBoolean("zramCompression", on).apply() - } - fun gameStorage(context: Context): String = prefs(context).getString("gameStorage", "") ?: "" diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index c1b0ab86d..839cb28a5 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -581,6 +581,17 @@ class SessionService : Service() { resumeAudio = { if (!pulse.setSinkSuspended(false)) Log.w(TAG, "could not resume audio sink") }, + zramControl = File(filesDir, "${LinuxRuntime.DIR}/tmp/.bl-zram"), + pagedOut = { processes, residentKb, swapKb, swapBeforeKb, millis -> + Log.i(TAG, "paged out $processes processes in $millis ms: resident ${residentKb / 1024} MB, " + + "zram ${swapBeforeKb / 1024} -> ${swapKb / 1024} MB") + mainHandler.post { + SessionEvents.record("session.paged_out", mapOf( + "processes" to processes, "residentMb" to residentKb / 1024, + "swapMb" to swapKb / 1024, "swapBeforeMb" to swapBeforeKb / 1024, "ms" to millis, + )) + } + }, ) mainHandler.post { updateSuspendPolicy() } updateDownloadMonitoring() @@ -661,8 +672,7 @@ class SessionService : Service() { guest.add("BL_SYNC_FALLBACK=" + (if (SessionPrefs.syncFallback(this)) "1" else "0")) guest.add("BL_FSYNC_FIRST=" + (if (SessionPrefs.fsyncFirst(this)) "1" else "0")) guest.add("BL_GAMESCOPE_REALTIME=" + (if (SessionPrefs.gamescopeRealtime(this)) "1" else "0")) - if (SessionState.mode == MODE_STEAM && SessionPrefs.zramCompression(this) && - com.droiddeck.launcher.core.ZramSupport.supported()) guest.add("BL_ZRAM=1") + if (com.droiddeck.launcher.core.ZramSupport.supported()) guest.add("BL_ZRAM=1") guest.add("BANNER_AUDIO_DIRECT_DECAY=0") val extra = extraEnv() + com.droiddeck.launcher.agent.AgentEnv.beginSession(this) if (extra.none { it.startsWith("TU_DEBUG=") }) guest.add("$GAME_TU_DEBUG=" + (tuOverride ?: if (tuDriver) "sysmem" else "")) @@ -1386,7 +1396,9 @@ class SessionService : Service() { "nativeSleep" to (nativeSleepToken != null), )) if (shouldSuspend) { - controller.freeze { success -> + controller.freeze( + pageOut = steamSleepToken != null || nativeSleepToken != null || !activityVisible || !screenOn, + ) { success -> mainHandler.post { if (gen != sessionGen || !SessionState.running) return@post suspendOperationPending = false diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt index d694d6786..268719f55 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt @@ -18,6 +18,8 @@ internal class SessionSuspendController( private val sleep: (Long) -> Unit = Thread::sleep, private val nowMillis: () -> Long = { System.nanoTime() / 1_000_000 }, private val executor: ExecutorService = Executors.newSingleThreadExecutor(), + private val zramControl: File? = null, + private val pagedOut: (processes: Int, residentKb: Long, swapKb: Long, swapBeforeKb: Long, millis: Long) -> Unit = { _, _, _, _, _ -> }, ) { private enum class Role { SESSION_ROOT, SESSION_CHILD, HELPER_ROOT, HELPER_CHILD } @@ -40,7 +42,7 @@ internal class SessionSuspendController( @Volatile private var closing = false private var frozen = emptyList() - fun freeze(completion: (Boolean) -> Unit) { + fun freeze(pageOut: Boolean = false, completion: (Boolean) -> Unit) { submit({ val records = frozen.associateByTo(LinkedHashMap()) { it.pid to it.started } var complete = false @@ -59,6 +61,7 @@ internal class SessionSuspendController( records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.HELPER_ROOT) } } + if (pageOut) pageOut(records, root.pid, helpers) val deadline = nowMillis() + FREEZE_TIMEOUT_MS var previous: Map? = null for (pass in 0 until FREEZE_MAX_PASSES) { @@ -233,6 +236,57 @@ internal class SessionSuspendController( return tasks.takeIf { stopped && it.isNotEmpty() } } + // A stopped process cannot run libblsession's SIGURG handler, so the page-out has to finish + // before the stop. Android's low-memory killer otherwise finds the frozen game resident in RAM + // with most of zram still free. + private fun pageOut(records: MutableMap, ProcessRecord>, root: Int, helpers: List) { + val control = zramControl?.takeIf { it.isFile } ?: return + try { + addSnapshot(records, root, helpers) + control.writeText("1") + } catch (_: IOException) { + return + } + val targets = records.values.filter { record -> + try { + File(procRoot, "${record.pid}/maps").useLines { lines -> lines.any { it.endsWith("/libblsession.so") } } + } catch (_: IOException) { + false + } + } + if (targets.isEmpty()) return + val start = nowMillis() + val before = memoryKb(targets) + for (record in targets) send(record, SIGURG) + var previous = before.second + var steady = 0 + while (steady < PAGE_OUT_STEADY_POLLS && nowMillis() - start < PAGE_OUT_TIMEOUT_MS) { + sleep(PAGE_OUT_POLL_MS) + val swap = memoryKb(targets).second + steady = if (swap == previous) steady + 1 else 0 + previous = swap + } + val after = memoryKb(targets) + pagedOut(targets.size, after.first, after.second, before.second, nowMillis() - start) + } + + private fun memoryKb(records: List): Pair { + var resident = 0L + var swap = 0L + for (record in records) { + try { + File(procRoot, "${record.pid}/status").useLines { lines -> + for (line in lines) { + val kb = line.substringAfter(':').trim().substringBefore(' ').toLongOrNull() ?: continue + if (line.startsWith("VmRSS:")) resident += kb else if (line.startsWith("VmSwap:")) swap += kb + } + } + } catch (_: IOException) { + } + } + return resident to swap + } + // PRoot traces every guest. A stop sent while it runs becomes a group stop that it restarts // thread by thread; once it is stopped, each thread that takes a SIGSTOP stays parked in its // signal-delivery stop. So the roots stop first and every guest thread gets its own SIGSTOP. @@ -310,6 +364,10 @@ internal class SessionSuspendController( private companion object { const val SIGSTOP = 19 const val SIGCONT = 18 + const val SIGURG = 23 + const val PAGE_OUT_POLL_MS = 250L + const val PAGE_OUT_STEADY_POLLS = 3 + const val PAGE_OUT_TIMEOUT_MS = 12_000L const val RESTORE_CHECKS = 80 const val FREEZE_TIMEOUT_MS = 8_000L const val FREEZE_MAX_PASSES = 256 diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index cb7ec9251..49564945f 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -65,8 +65,6 @@ class ModeSettings( val touchMode: String, val suspendPolicy: String, val steamDownloadsInBackground: Boolean = false, - val zramCompression: Boolean? = null, - val zramSupported: Boolean? = null, val pipSupported: Boolean = false, val pipAutoEnter: Boolean = false, val oscMode: String?, @@ -120,7 +118,6 @@ class ModeSettingsActions( val onTouch: (String) -> Unit, val onSuspendPolicy: (String) -> Unit, val onSteamDownloadsInBackground: (Boolean) -> Unit = {}, - val onZramCompression: (Boolean) -> Unit = {}, val onPipAutoEnter: (Boolean) -> Unit = {}, val onOsc: (String) -> Unit, val onBackActionsInverted: (Boolean) -> Unit = {}, @@ -326,12 +323,6 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: stringResource(R.string.mode_background_downloads_hint), s.steamDownloadsInBackground, onChange = a.onSteamDownloadsInBackground, ) - if (s.zramCompression != null) ToggleRow( - host, "zram", stringResource(R.string.mode_zram), - stringResource(if (s.zramSupported == false) R.string.mode_zram_unsupported else R.string.mode_zram_hint), - s.zramCompression && s.zramSupported != false, enabled = s.zramSupported == true, - onChange = a.onZramCompression, - ) if (s.syncBackend != null) ChoiceRow( host, "sync", stringResource(R.string.sync_backend_title), stringResource(R.string.sync_backend_hint), listOf( diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 8002743d4..c8944df41 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -287,9 +287,6 @@ «تلقائي» يوقف مؤقتًا ويستأنف عند الظهور. «أصلي» يجهّز Steam للسكون أولًا. «يدوي» ينتظر الضغط على «استئناف». «أبدًا» يُبقيه قيد التشغيل. مواصلة التنزيلات تأخير التعليق في الخلفية أثناء قيام Steam بالتنزيل. - ضغط zRAM - أثناء تشغيل اللعبة يتم ضغط ذاكرة عميل Steam إلى zram في الجهاز، فتحصل اللعبة على ذاكرة RAM أكبر. يسري عند تشغيل Steam في المرة القادمة. - غير متاح: لا يحتوي هذا الجهاز على zram أو swap، أو أن النواة أقدم من 5.4. «تلقائي» يوقف مؤقتًا في الخلفية ويستأنف عند الظهور. «يدوي» ينتظر الضغط على «استئناف». «أبدًا» يُبقيه قيد التشغيل. بدء التشغيل تشغيل Steam مع DroidDeck diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index fb9b4c3c9..502f0234f 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -287,9 +287,6 @@ Auto pausa y reanuda al mostrarse. Nativo prepara Steam para la suspensión primero. Manual espera a Reanudar. Nunca sigue ejecutándose. Mantener las descargas activas Retrasa la suspensión en segundo plano mientras Steam esté descargando. - Compresión zRAM - Mientras se ejecuta un juego, la memoria del cliente de Steam se comprime en la zram del dispositivo, lo que deja más RAM para el juego. Se aplica la próxima vez que se inicie Steam. - No disponible: este dispositivo no tiene swap activo, o su kernel es anterior a la 5.4. Auto pausa en segundo plano y reanuda al mostrarse. Manual espera a Reanudar. Nunca sigue ejecutándose. Inicio Iniciar Steam con DroidDeck diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 52c09a015..6a2f7582a 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -287,9 +287,6 @@ Автоматично поставя на пауза и продължава, когато се покаже отново. Системно подготвя Steam за заспиване първо. Ръчно изчаква „Продължи“. Никога продължава да работи. Продължаване на изтеглянията Отлага фоновото спиране, докато Steam активно изтегля. - zRAM компресия - Докато играта работи, паметта на Steam клиента се компресира в zram на устройството, така че играта получава повече RAM. Важи при следващото стартиране на Steam. - Недостъпно: устройството няма zram или swap, или ядрото е по-старо от 5.4. Автоматично поставя на пауза във фонов режим и продължава при показване. Ръчно изчаква „Продължи“. Никога продължава да работи. Стартиране Стартиране на Steam заедно с DroidDeck diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 9df47991c..404304380 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -287,9 +287,6 @@ Automaticky pozastaví relaci a obnoví ji při zobrazení. Nativně nejprve připraví Steam na spánek. Manuálně čeká na Obnovit. Nikdy běh nezastavuje. Nechat stahování běžet Oddálí pozastavení na pozadí, pokud Steam aktivně stahuje. - Komprese zRAM - Během hraní se paměť klienta Steam komprimuje do zram zařízení, takže hra má víc RAM. Platí při příštím spuštění Steamu. - Není k dispozici: zařízení nemá zram ani swap, nebo je jádro starší než 5.4. Automaticky pozastaví na pozadí a obnoví při zobrazení. Manuálně čeká na Obnovit. Nikdy běh nezastavuje. Spouštění Spustit Steam s DroidDeck diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 6b200711d..e043f3ac2 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -287,9 +287,6 @@ Auto sætter på pause og genoptager, når den vises. Native forbereder Steam til dvale først. Manuel venter på Genoptag. Aldrig fortsætter med at køre. Fortsæt downloads Udskyder baggrundspause, mens Steam aktivt downloader. - zRAM-komprimering - Mens et spil kører, komprimeres Steam-klientens hukommelse til enhedens zram, så spillet får mere RAM. Gælder næste gang Steam starter. - Ikke tilgængeligt: enheden har ingen zram eller swap, eller kernen er ældre end 5.4. Auto sætter på pause i baggrunden og genoptager, når den vises. Manuel venter på Genoptag. Aldrig fortsætter med at køre. Opstart Start Steam med DroidDeck diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d284aa127..6615713b1 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2520,9 +2520,6 @@ Komplettierer %1$s Std. %1$d Min. - zRAM-Komprimierung - Während ein Spiel läuft, wird der Arbeitsspeicher des Steam-Clients in das zram des Geräts komprimiert, sodass mehr RAM für das Spiel bleibt. Wird beim nächsten Start von Steam angewendet. - Nicht verfügbar: Dieses Gerät hat kein zram oder keinen Swap, oder sein Kernel ist älter als 5.4. Alle Einstellungen zurücksetzen Alle Einstellungen zurücksetzen? Anzeige-, Leistungs-, Controller-, Design-, Sprach- und Launcher-Einstellungen werden auf ihre Standardwerte zurückgesetzt. Deine Spiele, die Steam-Anmeldung, Shop-Konten, heruntergeladene Treiber und Speicherordner bleiben erhalten. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 409cda9ec..84f49be0c 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -287,9 +287,6 @@ Το Αυτόματο θέτει σε παύση και συνεχίζει όταν εμφανίζεται. Το Εγγενής προετοιμάζει πρώτα το Steam για αδρανοποίηση. Το Μη αυτόματο περιμένει το Συνέχιση. Το Ποτέ συνεχίζει να εκτελείται. Διατήρηση των λήψεων σε εξέλιξη Καθυστερεί την αναστολή παρασκηνίου όσο το Steam κάνει ενεργά λήψη. - Συμπίεση zRAM - Όσο τρέχει ένα παιχνίδι, η μνήμη του Steam συμπιέζεται στο zram της συσκευής, ώστε το παιχνίδι να έχει περισσότερη RAM. Ισχύει την επόμενη φορά που θα ξεκινήσει το Steam. - Μη διαθέσιμο: η συσκευή δεν έχει zram ή swap, ή ο πυρήνας είναι παλαιότερος από 5.4. Το Αυτόματο θέτει σε παύση στο παρασκήνιο και συνεχίζει όταν εμφανίζεται. Το Μη αυτόματο περιμένει το Συνέχιση. Το Ποτέ συνεχίζει να εκτελείται. Εκκίνηση Εκκίνηση του Steam με το DroidDeck diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 85e1bf0a4..92a895720 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2555,9 +2555,6 @@ Lo usa el cliente de Steam x86-64 Esta copia está en el cliente de Steam x86-64. Elige x86-64 en Cliente de Steam y desinstálala aquí. Esta copia está en el cliente de Steam ARM64. Elige ARM64 en Cliente de Steam y desinstálala aquí. - Compresión zRAM - Mientras se ejecuta un juego, la memoria del cliente de Steam se comprime en la zram del dispositivo, lo que deja más RAM para el juego. Se aplica la próxima vez que se inicie Steam. - No disponible: este dispositivo no tiene swap activo, o su kernel es anterior a la 5.4. No hay suficiente espacio libre: se necesitan %1$s y solo hay %2$s libres Espacio insuficiente (%1$s libres, se necesitan %2$s) Espacio libre desconocido diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index b6757f5e7..84b55022f 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -287,9 +287,6 @@ Automaattinen keskeyttää ja jatkaa, kun sovellus tulee näkyviin. Natiivi valmistelee Steamin lepotilaan ensin. Manuaalinen odottaa Jatka-painiketta. Ei koskaan pitää sovelluksen aina käynnissä. Pidä lataukset käynnissä Viivästytä taustakeskeytystä, kun Steam lataa aktiivisesti. - zRAM-pakkaus - Pelin pyöriessä Steam-asiakkaan muisti pakataan laitteen zramiin, jolloin pelille jää enemmän RAM-muistia. Astuu voimaan, kun Steam käynnistetään seuraavan kerran. - Ei käytettävissä: laitteessa ei ole zramia tai swappia, tai ydin on vanhempi kuin 5.4. Automaattinen keskeyttää taustalla ja jatkaa, kun sovellus tulee näkyviin. Manuaalinen odottaa Jatka-painiketta. Ei koskaan pitää sovelluksen aina käynnissä. Käynnistys Käynnistä Steam DroidDeckin kanssa diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index af743337c..c89604d26 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2555,9 +2555,6 @@ Télécharger des composants Les téléchargements sont partagés avec la page « Composants ». Choisissez une version téléchargée ci-dessus pour l\'utiliser au prochain lancement du jeu. Choisissez une version à télécharger - Compression zRAM - Pendant qu\'un jeu tourne, la mémoire du client Steam est compressée dans la zram de l\'appareil, ce qui laisse plus de RAM au jeu. S\'applique au prochain démarrage de Steam. - Indisponible : cet appareil n\'a pas de swap actif, ou son noyau est antérieur à la version 5.4. Espace libre insuffisant : %1$s nécessaires, seulement %2$s libres Espace insuffisant (%1$s libres, %2$s nécessaires) Espace libre inconnu diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 38eac1193..2dff496b2 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -287,9 +287,6 @@ ऑटो दिखने पर रुकता है और फिर से शुरू होता है। नेटिव पहले Steam को स्लीप के लिए तैयार करता है। मैनुअल फिर से शुरू करें का इंतज़ार करता है। कभी नहीं वाला चलता रहता है। डाउनलोड चलते रहने दें Steam के सक्रिय रूप से डाउनलोड करते समय बैकग्राउंड सस्पेंशन को टालें। - zRAM कम्प्रेशन - गेम चलने के दौरान Steam क्लाइंट की मेमोरी डिवाइस के zram में कम्प्रेस होती है, जिससे गेम को ज़्यादा RAM मिलती है। अगली बार Steam शुरू होने पर लागू होगा। - उपलब्ध नहीं: इस डिवाइस में zram या swap नहीं है, या कर्नेल 5.4 से पुराना है। ऑटो बैकग्राउंड में रुकता है और दिखने पर फिर से शुरू होता है। मैनुअल फिर से शुरू करें का इंतज़ार करता है। कभी नहीं वाला चलता रहता है। स्टार्टअप DroidDeck के साथ Steam शुरू करें diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index a307b5aed..e3edb7ef4 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -287,9 +287,6 @@ Az Automatikus szüneteltet, és megjelenéskor folytatja. A Natív előbb felkészíti a Steamet az altatásra. A Manuális megvárja a Folytatást. A Soha mindig futva tartja. Letöltések futtatásának fenntartása Elhalasztja a háttérbeli felfüggesztést, amíg a Steam aktívan letölt. - zRAM-tömörítés - Játék közben a Steam kliens memóriája a készülék zramjába tömörül, így a játéknak több RAM jut. A Steam következő indításakor lép életbe. - Nem elérhető: a készüléken nincs zram vagy swap, vagy a kernel régebbi az 5.4-nél. Az Automatikus szüneteltet a háttérben, és megjelenéskor folytatja. A Manuális megvárja a Folytatást. A Soha mindig futva tartja. Indítás Steam indítása a DroidDeckkel diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index f1cbe9543..8d3e41a1d 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -287,9 +287,6 @@ Otomatis menjeda dan melanjutkan saat ditampilkan. Native menyiapkan Steam untuk tidur terlebih dahulu. Manual menunggu Lanjutkan. Tidak pernah terus berjalan. Pertahankan unduhan tetap berjalan Menunda penjedaan latar belakang saat Steam sedang aktif mengunduh. - Kompresi zRAM - Saat bermain, memori klien Steam dipadatkan ke zram perangkat sehingga gim mendapat lebih banyak RAM. Berlaku saat Steam dijalankan berikutnya. - Tidak tersedia: perangkat ini tidak punya zram atau swap, atau kernelnya lebih lama dari 5.4. Otomatis menjeda di latar belakang dan melanjutkan saat ditampilkan. Manual menunggu Lanjutkan. Tidak pernah terus berjalan. Mulai Mulai Steam bersama DroidDeck diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 9493ae1b1..de3de5b33 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2545,9 +2545,6 @@ Completista %1$s h %1$d min - Compressione zRAM - Mentre un gioco è in esecuzione, la memoria del client Steam viene compressa nella zram del dispositivo, lasciando più RAM al gioco. Si applica al prossimo avvio di Steam. - Non disponibile: questo dispositivo non ha zram o swap, oppure il suo kernel è precedente alla 5.4. Ripristina tutte le impostazioni Ripristinare tutte le impostazioni? Le impostazioni di schermo, prestazioni, controller, tema, lingua e launcher tornano ai valori predefiniti. Giochi, accesso a Steam, account degli store, driver scaricati e cartelle di archiviazione vengono mantenuti. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 143351f82..d18f60b92 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -287,9 +287,6 @@ 自動では一時停止し、表示すると再開します。ネイティブでは先にSteamをスリープに備えさせます。手動では「再開」をタップするまで待機します。「しない」では実行し続けます。 ダウンロードを継続 Steamがダウンロード中は、バックグラウンドでの一時停止を遅らせます。 - zRAM 圧縮 - ゲームの実行中、Steam クライアントのメモリがデバイスの zram に圧縮され、ゲーム用のメモリが増えます。次回 Steam を起動したときに反映されます。 - 使用できません:このデバイスに zram もしくは swap がないか、カーネルが 5.4 未満です。 自動ではバックグラウンドで一時停止し、表示すると再開します。手動では「再開」をタップするまで待機します。「しない」では実行し続けます。 起動 DroidDeck起動時にSteamを起動 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 901a84e7d..647212802 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -287,9 +287,6 @@ 자동: 일시 중지되고 다시 표시되면 재개됩니다. 네이티브: 먼저 Steam을 절전에 대비시킵니다. 수동: \"재개\"를 탭할 때까지 기다립니다. 안 함: 계속 실행됩니다. 다운로드 계속 진행 Steam이 다운로드 중일 때는 백그라운드 일시 중지를 미룹니다. - zRAM 압축 - 게임을 실행하는 동안 Steam 클라이언트의 메모리가 기기의 zram으로 압축되어 게임에 더 많은 메모리가 남습니다. 다음에 Steam을 시작할 때 적용됩니다. - 사용할 수 없음: 이 기기에 zram 또는 swap이 없거나 커널이 5.4 미만입니다. 자동: 백그라운드에서 일시 중지되고 다시 표시되면 재개됩니다. 수동: \"재개\"를 탭할 때까지 기다립니다. 안 함: 계속 실행됩니다. 시작 DroidDeck을 열 때 Steam 시작 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 6c5acab20..c9962a55f 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -287,9 +287,6 @@ Auto menjeda dan menyambung semula apabila dipaparkan. Asli menyediakan Steam untuk tidur dahulu. Manual menunggu Sambung. Jangan sekali-kali terus berjalan. Teruskan muat turun Lambatkan penjedaan latar belakang semasa Steam aktif memuat turun. - Pemampatan zRAM - Semasa bermain, memori klien Steam dimampatkan ke zram peranti supaya permainan mendapat lebih RAM. Berkuat kuasa pada permulaan Steam seterusnya. - Tidak tersedia: peranti ini tiada zram atau swap, atau kernelnya lebih lama daripada 5.4. Auto menjeda di latar belakang dan menyambung semula apabila dipaparkan. Manual menunggu Sambung. Jangan sekali-kali terus berjalan. Permulaan Mulakan Steam bersama DroidDeck diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 6bbab5819..c870a048e 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -287,9 +287,6 @@ Auto setter på pause og gjenopptar når den vises. Nativ forbereder Steam på hvilemodus først. Manuell venter på Gjenoppta. Aldri lar den fortsette å kjøre. La nedlastinger fortsette Utsetter bakgrunnspause mens Steam laster aktivt ned. - zRAM-komprimering - Mens et spill kjører, komprimeres minnet til Steam-klienten til enhetens zram, slik at spillet får mer RAM. Gjelder neste gang Steam starter. - Ikke tilgjengelig: enheten har ingen zram eller swap, eller kjernen er eldre enn 5.4. Auto setter på pause i bakgrunnen og gjenopptar når den vises. Manuell venter på Gjenoppta. Aldri lar den fortsette å kjøre. Oppstart Start Steam med DroidDeck diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 12400b3e8..dc3e6c42c 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -287,9 +287,6 @@ Automatisch pauzeert en hervat zodra de app weer zichtbaar is. Systeemeigen maakt Steam eerst klaar voor de slaapstand. Handmatig wacht op Hervatten. Nooit laat alles doorlopen. Downloads laten doorlopen Stel onderbreken op de achtergrond uit zolang Steam actief downloadt. - zRAM-compressie - Terwijl een game draait wordt het geheugen van de Steam-client gecomprimeerd naar de zram van het apparaat, zodat de game meer RAM heeft. Geldt de volgende keer dat Steam start. - Niet beschikbaar: dit apparaat heeft geen zram of swap, of de kernel is ouder dan 5.4. Automatisch pauzeert op de achtergrond en hervat zodra de app weer zichtbaar is. Handmatig wacht op Hervatten. Nooit laat alles doorlopen. Opstarten Steam starten met DroidDeck diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index d80cd8cec..666a47b29 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -287,9 +287,6 @@ „Auto” wstrzymuje i wznawia po powrocie. „Natywnie” najpierw przygotowuje Steam do uśpienia. „Ręcznie” czeka na „Wznów”. „Nigdy” nie przerywa działania. Nie przerywaj pobierania Opóźnia wstrzymanie w tle, gdy Steam aktywnie pobiera. - Kompresja zRAM - Gdy gra działa, pamięć klienta Steam jest kompresowana do zram urządzenia, więc gra ma więcej RAM-u. Zadziała przy następnym uruchomieniu Steam. - Niedostępne: to urządzenie nie ma zram ani swapu, albo jądro jest starsze niż 5.4. „Auto” wstrzymuje w tle i wznawia po powrocie. „Ręcznie” czeka na „Wznów”. „Nigdy” nie przerywa działania. Uruchamianie Uruchamiaj Steam z DroidDeck diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index fed2cf78e..1ef3830ae 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -2553,9 +2553,6 @@ Repor Repor também os perfis de jogo (Proton, FEX, componentes e ambiente de cada jogo e o ambiente do perfil Padrão) Termine a sessão antes de repor as definições. - Compressão zRAM - Indisponível: este dispositivo não tem swap ativo ou o kernel é anterior à versão 5.4. - Enquanto um jogo está a correr, a memória do cliente Steam é comprimida na zram do dispositivo, deixando mais RAM para o jogo. Aplica-se a partir da próxima vez que o Steam iniciar. Espaço insuficiente (%1$s livres, são necessários %2$s) Espaço livre insuficiente: são necessários %1$s, só há %2$s livres Espaço livre desconhecido diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 37559d301..9ef9a8606 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -2545,9 +2545,6 @@ Completista %1$s h %1$d min - Compressão zRAM - Enquanto um jogo roda, a memória do cliente Steam é comprimida na zram do dispositivo, deixando mais RAM para o jogo. Vale a partir da próxima vez que a Steam iniciar. - Indisponível: este dispositivo não tem swap ativo, ou o kernel é anterior à versão 5.4. Redefinir todas as configurações Redefinir todas as configurações? As configurações de tela, desempenho, controle, tema, idioma e inicializador voltam ao padrão. Seus jogos, o login da Steam, as contas das lojas, os drivers baixados e as pastas de armazenamento são mantidos. diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 5ddf3f0a5..a70b63efc 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -287,9 +287,6 @@ Automat pune pe pauză și reia atunci când este afișată. Nativ pregătește Steam pentru repaus mai întâi. Manual așteaptă Reluare. Niciodată continuă să ruleze. Continuă descărcările Întârzie suspendarea în fundal cât timp Steam descarcă activ. - Compresie zRAM - În timpul jocului, memoria clientului Steam este comprimată în zram-ul dispozitivului, astfel încât jocul primește mai mult RAM. Se aplică la următoarea pornire a Steam. - Indisponibil: dispozitivul nu are zram sau swap, ori kernelul e mai vechi decât 5.4. Automat pune pe pauză în fundal și reia atunci când este afișată. Manual așteaptă Reluare. Niciodată continuă să ruleze. Pornire Pornește Steam odată cu DroidDeck diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 83295afb9..bab62122f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2580,9 +2580,6 @@ Скачать компоненты Загрузки общие со страницей «Компоненты». Выберите выше скачанную версию, чтобы использовать её при следующем запуске игры. Выберите версию для скачивания - Сжатие zRAM - Пока идёт игра, память клиента Steam сжимается в zram устройства, и игре остаётся больше ОЗУ. Применяется при следующем запуске Steam. - Недоступно: на устройстве нет zram или swap, либо его ядро старше 5.4. Недостаточно свободного места: нужно %1$s, свободно только %2$s Недостаточно места (свободно %1$s, нужно %2$s) Свободное место неизвестно diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 3bab33523..8b9be87c2 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -287,9 +287,6 @@ Auto pausar och återupptar när den visas. Inbyggd förbereder Steam för vila först. Manuell väntar på Återuppta. Aldrig fortsätter köra. Håll nedladdningar igång Skjut upp bakgrundspausning medan Steam aktivt laddar ned. - zRAM-komprimering - Medan ett spel körs komprimeras Steam-klientens minne till enhetens zram så att spelet får mer RAM. Gäller nästa gång Steam startar. - Inte tillgängligt: enheten har ingen zram eller swap, eller så är kärnan äldre än 5.4. Auto pausar i bakgrunden och återupptar när den visas. Manuell väntar på Återuppta. Aldrig fortsätter köra. Uppstart Starta Steam med DroidDeck diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 621087784..648aff3f4 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -287,9 +287,6 @@ อัตโนมัติจะหยุดชั่วคราวและดำเนินการต่อเมื่อแสดงผล ดั้งเดิมเตรียม Steam เข้าสู่โหมดพักก่อน กำหนดเองรอการกดดำเนินการต่อ ไม่มีวันทำงานต่อเนื่องตลอด ให้การดาวน์โหลดทำงานต่อไป เลื่อนการหยุดพื้นหลังชั่วคราวออกไปขณะ Steam กำลังดาวน์โหลดอยู่ - การบีบอัด zRAM - ระหว่างเล่นเกม หน่วยความจำของไคลเอนต์ Steam จะถูกบีบอัดลง zram ของอุปกรณ์ ทำให้เกมได้ RAM มากขึ้น มีผลเมื่อเริ่ม Steam ครั้งถัดไป - ใช้ไม่ได้: อุปกรณ์นี้ไม่มี zram หรือ swap หรือเคอร์เนลเก่ากว่า 5.4 อัตโนมัติจะหยุดชั่วคราวเมื่ออยู่ในพื้นหลังและดำเนินการต่อเมื่อแสดงผล กำหนดเองรอการกดดำเนินการต่อ ไม่มีวันทำงานต่อเนื่องตลอด การเริ่มทำงาน เริ่ม Steam พร้อมกับ DroidDeck diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index a0e60d8c6..58cebb95e 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -2520,9 +2520,6 @@ Tamamlayıcı %1$s sa %1$d dk - zRAM sıkıştırma - Bir oyun çalışırken Steam istemcisinin belleği cihazın zram\'ına sıkıştırılır ve oyuna daha fazla RAM kalır. Steam\'in bir sonraki başlatılışında uygulanır. - Kullanılamıyor: bu cihazda zram veya swap yok ya da çekirdeği 5.4\'ten eski. Tüm ayarları sıfırla Tüm ayarlar sıfırlansın mı? Ekran, performans, denetleyici, tema, dil ve başlatıcı ayarları varsayılanlarına döner. Oyunlarınız, Steam oturum bilgileriniz, mağaza hesaplarınız, indirilen sürücüler ve depolama klasörleriniz korunur. diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 5bc3cd8f1..3f7cf70c2 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -287,9 +287,6 @@ «Авто» призупиняє сеанс і відновлює його, коли додаток знову на екрані. «Нативний» спершу готує Steam до сну. «Вручну» чекає на «Відновити». «Ніколи» — сеанс працює далі. Не переривати завантаження Відкладає призупинення у фоні, поки Steam активно щось завантажує. - Стиснення zRAM - Під час гри пам\'ять клієнта Steam стискається в zram пристрою, тож гра отримує більше RAM. Застосується під час наступного запуску Steam. - Недоступно: на пристрої немає zram або swap, чи ядро старіше за 5.4. «Авто» призупиняє сеанс у фоні й відновлює, коли додаток знову на екрані. «Вручну» чекає на «Відновити». «Ніколи» — сеанс працює далі. Запуск Запускати Steam разом із DroidDeck diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 874e91cd7..572d7e943 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -287,9 +287,6 @@ Tự động tạm dừng và tiếp tục khi được hiển thị lại. Gốc chuẩn bị Steam để ngủ trước. Thủ công chờ nhấn Tiếp tục. Không bao giờ giữ nó luôn chạy. Giữ tải xuống tiếp tục chạy Trì hoãn tạm dừng nền khi Steam đang tải xuống. - Nén zRAM - Khi chơi, bộ nhớ của ứng dụng Steam được nén vào zram của thiết bị để game có thêm RAM. Áp dụng từ lần khởi động Steam tiếp theo. - Không khả dụng: thiết bị không có zram hoặc swap, hoặc nhân cũ hơn 5.4. Tự động tạm dừng ở nền và tiếp tục khi hiển thị lại. Thủ công chờ nhấn Tiếp tục. Không bao giờ giữ nó luôn chạy. Khởi động Khởi động Steam cùng DroidDeck diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 960fdd825..aa0b89651 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -287,9 +287,6 @@ 自动会暂停并在显示时恢复。原生会先让 Steam 做好睡眠准备。手动会等待点按恢复。“从不”则保持运行。 后台继续下载 Steam 正在下载时推迟后台暂停。 - zRAM 压缩 - 游戏运行时,Steam 客户端的内存会压缩进设备的 zram,为游戏留出更多内存。下次启动 Steam 时生效。 - 不可用:此设备没有 zram 或 swap,或其内核低于 5.4。 自动会在后台暂停并在显示时恢复。手动会等待点按恢复。“从不”则保持运行。 启动 随 DroidDeck 启动 Steam diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 00c99dd55..78096f4b6 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -287,9 +287,6 @@ 「自動」會暫停,並在重新顯示時繼續;「原生」會先讓 Steam 準備好休眠;「手動」會等你按「繼續」;「從不」會保持執行。 保持下載進行 Steam 正在下載時,延遲在背景暫停。 - zRAM 壓縮 - 遊戲運行時,Steam 用戶端的記憶體會壓縮進裝置的 zram,為遊戲騰出更多記憶體。下次啟動 Steam 時生效。 - 無法使用:此裝置沒有 zram 或 swap,或核心版本低於 5.4。 「自動」會在背景時暫停,並在重新顯示時繼續;「手動」會等你按「繼續」;「從不」會保持執行。 啟動 隨 DroidDeck 啟動 Steam diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 941437a04..dd52624c5 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -287,9 +287,6 @@ 「自動」會暫停,並在重新顯示時繼續。「原生」會先讓 Steam 做好睡眠準備。「手動」會等你點擊「繼續」。「從不」則會持續執行。 讓下載持續進行 Steam 正在下載時,延後在背景暫停。 - zRAM 壓縮 - 遊戲執行時,Steam 用戶端記憶體會壓縮進裝置的 zram,為遊戲騰出更多記憶體。下次啟動 Steam 時生效。 - 無法使用:此裝置沒有 zram 或 swap,或核心版本低於 5.4。 「自動」會在背景時暫停,並在重新顯示時繼續。「手動」會等你點擊「繼續」。「從不」則會持續執行。 啟動 隨 DroidDeck 啟動 Steam diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f9bce6977..09f84d9b8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -292,9 +292,6 @@ Auto pauses and resumes when shown. Native prepares Steam for sleep first. Manual waits for Resume. Never keeps running. Keep downloads running Delay background suspension while Steam is actively downloading. - zRAM compression - While a game runs, the Steam client\'s memory is compressed into the device\'s zram, leaving more RAM for the game. Applies the next time Steam starts. - Unavailable: this device has no zram or swap, or its kernel is older than 5.4. Auto pauses in the background and resumes when shown. Manual waits for Resume. Never keeps running. Startup Start Steam with DroidDeck diff --git a/app/src/test/java/com/droiddeck/launcher/core/ZramSupportTest.kt b/app/src/test/java/com/droiddeck/launcher/core/ZramSupportTest.kt index 056e3a055..033efb578 100644 --- a/app/src/test/java/com/droiddeck/launcher/core/ZramSupportTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/core/ZramSupportTest.kt @@ -14,20 +14,6 @@ class ZramSupportTest { assertFalse(ZramSupport.kernelAtLeast("", 5, 4)) } - @Test - fun underTwelveGbFromAdvertisedOrTotalMemory() { - val gb = 1_000_000_000L - val gib = 1L shl 30 - assertTrue(ZramSupport.belowTwelveGb(8 * gb, 0)) - assertTrue(ZramSupport.belowTwelveGb(8 * gib, 0)) - assertFalse(ZramSupport.belowTwelveGb(12 * gb, 0)) - assertFalse(ZramSupport.belowTwelveGb(12 * gib, 0)) - assertTrue(ZramSupport.belowTwelveGb(0, 7_400_000L * 1024)) - assertTrue(ZramSupport.belowTwelveGb(0, 9_300_000L * 1024)) - assertFalse(ZramSupport.belowTwelveGb(0, 11_100_000L * 1024)) - assertFalse(ZramSupport.belowTwelveGb(0, 15_600_380L * 1024)) - } - @Test fun swapTotalComesFromMeminfo() { assertEquals(12582908L, ZramSupport.swapTotalKb(listOf("MemTotal: 15600380 kB", "SwapTotal: 12582908 kB"))) diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionSuspendControllerTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionSuspendControllerTest.kt index 61e95da49..b8eafcc1b 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionSuspendControllerTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionSuspendControllerTest.kt @@ -26,6 +26,30 @@ class SessionSuspendControllerTest { } } + @Test fun pageOutRunsInPreloadedGuestsBeforeTheyStop() { + val fixture = fixture(17) + fixture.control.writeText("0") + fixture.controller.freeze(pageOut = true) { assertTrue(it) } + val urgent = fixture.signals.filter { it.signal == URG } + assertEquals(listOf(GAME), urgent.map { it.pid }) + assertTrue(fixture.signals.indexOfFirst { it.signal == URG } < fixture.signals.indexOfFirst { it.signal == STOP }) + assertEquals("1", fixture.control.readText()) + assertEquals(listOf(Triple(1, 1_000_000L, 3_000_000L)), fixture.pagedOut) + assertTrue(fixture.game.threads.all { it.state == 't' }) + } + + @Test fun noPageOutWithoutTheRequestOrTheControlFile() { + val visible = fixture(17) + visible.control.writeText("0") + visible.controller.freeze { assertTrue(it) } + assertFalse(visible.signals.any { it.signal == URG }) + assertEquals("0", visible.control.readText()) + val disabled = fixture(17) + disabled.controller.freeze(pageOut = true) { assertTrue(it) } + assertFalse(disabled.signals.any { it.signal == URG }) + assertTrue(disabled.pagedOut.isEmpty()) + } + @Test fun cpuMustBeStableAcrossTwoCompleteSnapshots() { val fixture = fixture(17) var changes = 0 @@ -253,13 +277,13 @@ class SessionSuspendControllerTest { } private fun fixture(count: Int, executor: QueueExecutor = InlineExecutor()) = - Fixture(temporary.newFolder(), count, executor) + Fixture(temporary.newFolder(), temporary.newFolder(), count, executor) private data class Task(val tid: Int, var started: Long, var state: Char, var cpu: Long = 0) private data class Group(val pid: Int, var parent: Int, var tracer: Int, val threads: MutableList) private data class SentSignal(val pid: Int, val signal: Int) - private class Fixture(val proc: File, count: Int, executor: QueueExecutor) { + private class Fixture(val proc: File, tmp: File, count: Int, executor: QueueExecutor) { val root = Group(ROOT, 9000, 0, mutableListOf(Task(ROOT, 1000, 'S'))) val game = Group(GAME, ROOT, ROOT, MutableList(count) { Task(GAME + it, 2000L + it, 'S') }) val groups = mutableListOf(root, game) @@ -276,10 +300,17 @@ class SessionSuspendControllerTest { var emptyGameTasks = false var rootGone = false var afterSleep: (() -> Unit)? = null + val control = File(tmp, ".bl-zram") + val pagedOut = mutableListOf>() + private var gameResidentKb = 4_000_000L + private var gameSwapKb = 0L + private var pagingOut = false private var continuing = false val controller = SessionSuspendController( { rootPid }, { helpers }, { audioSuspends++ }, { audioResumes++ }, proc, ::signal, ::threadSignal, ::sleep, { now }, executor, + zramControl = control, + pagedOut = { processes, resident, swap, _, _ -> pagedOut += Triple(processes, resident, swap) }, ) init { sync() } @@ -293,6 +324,8 @@ class SessionSuspendControllerTest { else group.threads.firstOrNull { it.state !in listOf('T', 't', 'Z', 'X') && !(neverStop && it == game.threads.last()) }?.state = 't' + } else if (signal == URG) { + pagingOut = true } else if (signal == CONT) { continuing = true if (!(pid == GAME && blockGameContinue)) group.threads.forEach { if (it.state != 'Z') it.state = 'S' } @@ -311,6 +344,10 @@ class SessionSuspendControllerTest { private fun sleep(millis: Long) { now += millis + if (pagingOut && gameResidentKb > 1_000_000L) { + gameResidentKb -= 1_000_000L + gameSwapKb += 1_000_000L + } if (root.threads.first().state != 'T' && !continuing) game.threads.forEach { if (it.state != 'Z') { it.state = 'S'; it.cpu++ } } @@ -326,7 +363,11 @@ class SessionSuspendControllerTest { val stat = File(dir, "stat") if (group == game && missingGameStat) stat.delete() else stat.writeText(stat(group, group.threads.first())) - File(dir, "status").writeText("TracerPid: ${group.tracer}\n") + File(dir, "status").writeText( + "TracerPid: ${group.tracer}\n" + + if (group == game) "VmRSS:\t$gameResidentKb kB\nVmSwap:\t$gameSwapKb kB\n" else "", + ) + if (group == game) File(dir, "maps").writeText("7f00000000-7f00001000 r-xp 00000000 fe:05 42 /usr/local/lib/libblsession.so\n") val taskDir = File(dir, "task") if (group == game && missingGameTasks) { taskDir.deleteRecursively(); continue } taskDir.mkdirs() @@ -375,5 +416,6 @@ class SessionSuspendControllerTest { const val GAME = 200 const val STOP = 19 const val CONT = 18 + const val URG = 23 } } diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 39ee3c8a6..467aa0fdc 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -60,6 +60,9 @@ clear_stale_tmp() { return 0 } [ -z "${BL_INSIDE:-}" ] && clear_stale_tmp +if [ -z "${BL_INSIDE:-}" ]; then + if [ "${BL_ZRAM:-0}" = 1 ]; then printf 0 > /tmp/.bl-zram; else rm -f /tmp/.bl-zram; fi +fi steam_update_stuck() { [ "$1" -lt 60 ] || steam_updates=0 @@ -441,7 +444,6 @@ case $mode in step "client cores: $BL_CLIENT_CPUS" fi if [ "${BL_ZRAM:-0}" = 1 ]; then - printf 0 > /tmp/.bl-zram step "zRAM compression: on" ( bl_zram_scan() { @@ -490,8 +492,6 @@ case $mode in nap 5 done ) & - else - rm -f /tmp/.bl-zram fi if [ "${BL_LAUNCHER_CORES:-1}" != 0 ] && [ -n "${BL_LAUNCH_DIR:-}" ] && [ -z "${BL_DESKTOP:-}" ] \ && [ "${BL_STEAM_UI:-}" != desktop ] && [ -x /usr/local/bin/droiddeck-launcher-cores ]; then diff --git a/tools/linuxfs/preload/zram.c b/tools/linuxfs/preload/zram.c index 565e0f7dd..f5e09cf48 100644 --- a/tools/linuxfs/preload/zram.c +++ b/tools/linuxfs/preload/zram.c @@ -148,8 +148,6 @@ static void zr_child(void) { } __attribute__((constructor)) static void zr_init(void) { - const char *name = program_invocation_short_name; - if (strcmp(name, "steam") != 0 && strcmp(name, "steamwebhelper") != 0) return; void *libc = dlopen("libc.so.6", RTLD_LAZY | RTLD_NOLOAD); if (libc == NULL) return; zr_sys = (long (*)(long, ...))dlsym(libc, "syscall"); From 1b1493973109f05ee8af3d43e8f48aae7a6ec80a Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 22 Oct 2026 10:01:49 -0400 Subject: [PATCH 05/11] fix: suspend page-out waits for each handler's ack and can be cancelled --- .../launcher/session/SessionService.kt | 27 +++++++-- .../session/SessionSuspendController.kt | 40 +++++++++---- .../session/SessionSuspendControllerTest.kt | 58 +++++++++++++++++-- tools/linuxfs/preload/zram.c | 21 +++++++ tools/tests/test_zram_preload.py | 8 ++- 5 files changed, 130 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 839cb28a5..ea34327a6 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -91,6 +91,8 @@ class SessionService : Service() { private var suspendOperationPending = false private var pipTask = false private var suspendAttemptFailed = false + private var freezePending = false + private var freezeCancelled = false private var pendingSteamGameId: String? = null private var pendingSteamGameGeneration = -1 // Bumped to cancel a running game-exit watch (a new launch, or the session ending). @@ -287,6 +289,7 @@ class SessionService : Service() { steamSleepToken = null completeNativeSleep() suspendOperationPending = false + freezePending = false suspendAttemptFailed = false suspendController = null SessionState.suspended = false @@ -582,12 +585,12 @@ class SessionService : Service() { if (!pulse.setSinkSuspended(false)) Log.w(TAG, "could not resume audio sink") }, zramControl = File(filesDir, "${LinuxRuntime.DIR}/tmp/.bl-zram"), - pagedOut = { processes, residentKb, swapKb, swapBeforeKb, millis -> - Log.i(TAG, "paged out $processes processes in $millis ms: resident ${residentKb / 1024} MB, " + - "zram ${swapBeforeKb / 1024} -> ${swapKb / 1024} MB") + pagedOut = { processes, unacknowledged, residentKb, swapKb, swapBeforeKb, millis -> + Log.i(TAG, "paged out $processes processes ($unacknowledged unacknowledged) in $millis ms: " + + "resident ${residentKb / 1024} MB, zram ${swapBeforeKb / 1024} -> ${swapKb / 1024} MB") mainHandler.post { SessionEvents.record("session.paged_out", mapOf( - "processes" to processes, "residentMb" to residentKb / 1024, + "processes" to processes, "unacknowledged" to unacknowledged, "residentMb" to residentKb / 1024, "swapMb" to swapKb / 1024, "swapBeforeMb" to swapBeforeKb / 1024, "ms" to millis, )) } @@ -1386,6 +1389,10 @@ class SessionService : Service() { SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && prepared && !downloadsBlockingSuspend else -> false } + if (freezePending && !shouldSuspend && !freezeCancelled) { + freezeCancelled = true + controller.cancelFreeze() + } if (suspendOperationPending || suspendAttemptFailed || shouldSuspend == SessionState.suspended) return val gen = sessionGen suspendOperationPending = true @@ -1396,12 +1403,15 @@ class SessionService : Service() { "nativeSleep" to (nativeSleepToken != null), )) if (shouldSuspend) { + freezePending = true + freezeCancelled = false controller.freeze( pageOut = steamSleepToken != null || nativeSleepToken != null || !activityVisible || !screenOn, ) { success -> mainHandler.post { if (gen != sessionGen || !SessionState.running) return@post suspendOperationPending = false + freezePending = false if (success) { SessionState.suspended = true SessionEvents.transition(SessionPhase.SUSPENDED, "session.suspended") @@ -1410,8 +1420,12 @@ class SessionService : Service() { } else { completeNativeSleep() completeSteamSleep() - suspendAttemptFailed = true - Log.w(TAG, "could not confirm that the session stopped") + if (freezeCancelled) { + SessionEvents.record("session.suspend_cancelled") + } else { + suspendAttemptFailed = true + Log.w(TAG, "could not confirm that the session stopped") + } } updateSuspendPolicy() } @@ -1582,6 +1596,7 @@ class SessionService : Service() { GameThreadHints.stop() SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status)) suspendOperationPending = false + freezePending = false launchWatcher?.stopWatching() launchWatcher = null syncWatcher?.stopWatching() diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt index 268719f55..5d0e32f99 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt @@ -19,7 +19,8 @@ internal class SessionSuspendController( private val nowMillis: () -> Long = { System.nanoTime() / 1_000_000 }, private val executor: ExecutorService = Executors.newSingleThreadExecutor(), private val zramControl: File? = null, - private val pagedOut: (processes: Int, residentKb: Long, swapKb: Long, swapBeforeKb: Long, millis: Long) -> Unit = { _, _, _, _, _ -> }, + private val pagedOut: (processes: Int, unacknowledged: Int, residentKb: Long, swapKb: Long, swapBeforeKb: Long, millis: Long) -> Unit = + { _, _, _, _, _, _ -> }, ) { private enum class Role { SESSION_ROOT, SESSION_CHILD, HELPER_ROOT, HELPER_CHILD } @@ -40,9 +41,11 @@ internal class SessionSuspendController( private data class TaskIdentity(val process: ProcessRecord, val tid: Int, val started: Long) @Volatile private var closing = false + @Volatile private var cancelled = false private var frozen = emptyList() fun freeze(pageOut: Boolean = false, completion: (Boolean) -> Unit) { + cancelled = false submit({ val records = frozen.associateByTo(LinkedHashMap()) { it.pid to it.started } var complete = false @@ -65,14 +68,14 @@ internal class SessionSuspendController( val deadline = nowMillis() + FREEZE_TIMEOUT_MS var previous: Map? = null for (pass in 0 until FREEZE_MAX_PASSES) { - if (closing || nowMillis() >= deadline) break + if (closing || cancelled || nowMillis() >= deadline) break try { if (readProcess(root.pid)?.started != root.started) break addSnapshot(records, root.pid, helpers) if (readProcess(root.pid)?.started != root.started) break stop(records.values.toList()) sleep(if (pass == 0) 70L else 35L) - if (closing || nowMillis() >= deadline) break + if (closing || cancelled || nowMillis() >= deadline) break val current = stoppedSnapshot(records.values.toList(), root) if (current != null && current == previous) { complete = true @@ -100,6 +103,11 @@ internal class SessionSuspendController( }, { completion(false) }) } + // A freeze still queued or paging out stops before its next SIGSTOP pass and rolls back. + fun cancelFreeze() { + cancelled = true + } + fun resume(completion: (Boolean) -> Unit) { submit({ val records = frozen @@ -255,21 +263,29 @@ internal class SessionSuspendController( } } if (targets.isEmpty()) return + val acks = targets.associateWith { File(control.parentFile, "${control.name}.ack.${it.pid}") } + acks.values.forEach { it.delete() } val start = nowMillis() val before = memoryKb(targets) for (record in targets) send(record, SIGURG) - var previous = before.second - var steady = 0 - while (steady < PAGE_OUT_STEADY_POLLS && nowMillis() - start < PAGE_OUT_TIMEOUT_MS) { + // A program can replace libblsession's SIGURG handler (PyInstaller's bootloader, the Go runtime); + // such a process never creates its ack and is not waited for once the start window has passed. + val waiting = targets.toMutableList() + while (!closing && !cancelled && nowMillis() - start < PAGE_OUT_TIMEOUT_MS) { + val started = nowMillis() - start >= PAGE_OUT_START_MS + waiting.removeAll { val ack = acks.getValue(it); ack.length() > 0 || (started && !ack.exists()) || isGone(it) } + if (waiting.isEmpty()) break sleep(PAGE_OUT_POLL_MS) - val swap = memoryKb(targets).second - steady = if (swap == previous) steady + 1 else 0 - previous = swap } + val unacknowledged = targets.count { acks.getValue(it).length() == 0L && !isGone(it) } + acks.values.forEach { it.delete() } val after = memoryKb(targets) - pagedOut(targets.size, after.first, after.second, before.second, nowMillis() - start) + pagedOut(targets.size, unacknowledged, after.first, after.second, before.second, nowMillis() - start) } + private fun isGone(record: ProcessRecord): Boolean = + try { readProcess(record.pid)?.started != record.started } catch (_: Exception) { false } + private fun memoryKb(records: List): Pair { var resident = 0L var swap = 0L @@ -365,8 +381,8 @@ internal class SessionSuspendController( const val SIGSTOP = 19 const val SIGCONT = 18 const val SIGURG = 23 - const val PAGE_OUT_POLL_MS = 250L - const val PAGE_OUT_STEADY_POLLS = 3 + const val PAGE_OUT_POLL_MS = 100L + const val PAGE_OUT_START_MS = 2_000L const val PAGE_OUT_TIMEOUT_MS = 12_000L const val RESTORE_CHECKS = 80 const val FREEZE_TIMEOUT_MS = 8_000L diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionSuspendControllerTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionSuspendControllerTest.kt index b8eafcc1b..02b38143e 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionSuspendControllerTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionSuspendControllerTest.kt @@ -35,7 +35,43 @@ class SessionSuspendControllerTest { assertTrue(fixture.signals.indexOfFirst { it.signal == URG } < fixture.signals.indexOfFirst { it.signal == STOP }) assertEquals("1", fixture.control.readText()) assertEquals(listOf(Triple(1, 1_000_000L, 3_000_000L)), fixture.pagedOut) + assertEquals(0, fixture.unacknowledged) assertTrue(fixture.game.threads.all { it.state == 't' }) + assertFalse(File(fixture.control.parentFile, ".bl-zram.ack.$GAME").exists()) + } + + @Test fun unchangedSwapIsNotTakenForAFinishedPageOut() { + val fixture = fixture(17) + fixture.control.writeText("0") + fixture.handlerStalls = true + var stoppedEarly = false + fixture.afterSleep = { + if (fixture.now == 3000L) fixture.handlerStalls = false + if (fixture.handlerStalls && fixture.signals.any { it.signal == STOP }) stoppedEarly = true + } + fixture.controller.freeze(pageOut = true) { assertTrue(it) } + assertFalse(stoppedEarly) + assertEquals(0, fixture.unacknowledged) + } + + @Test fun replacedHandlerIsNotWaitedForPastTheStartWindow() { + val fixture = fixture(17) + fixture.control.writeText("0") + fixture.handlerReplaced = true + fixture.controller.freeze(pageOut = true) { assertTrue(it) } + assertEquals(1, fixture.unacknowledged) + assertTrue(fixture.now in 2000L..3000L) + } + + @Test fun cancelledFreezeRollsBackWithoutStopping() { + val fixture = fixture(17) + fixture.control.writeText("0") + fixture.handlerStalls = true + fixture.afterSleep = { if (fixture.now >= 500) fixture.controller.cancelFreeze() } + fixture.controller.freeze(pageOut = true) { assertFalse(it) } + assertFalse(fixture.signals.any { it.signal == STOP }) + assertTrue(fixture.game.threads.all { it.state == 'S' }) + assertEquals(1, fixture.audioResumes) } @Test fun noPageOutWithoutTheRequestOrTheControlFile() { @@ -305,12 +341,18 @@ class SessionSuspendControllerTest { private var gameResidentKb = 4_000_000L private var gameSwapKb = 0L private var pagingOut = false + var unacknowledged = -1 + var handlerStalls = false + var handlerReplaced = false private var continuing = false val controller = SessionSuspendController( { rootPid }, { helpers }, { audioSuspends++ }, { audioResumes++ }, proc, ::signal, ::threadSignal, ::sleep, { now }, executor, zramControl = control, - pagedOut = { processes, resident, swap, _, _ -> pagedOut += Triple(processes, resident, swap) }, + pagedOut = { processes, unacknowledged, resident, swap, _, _ -> + pagedOut += Triple(processes, resident, swap) + this.unacknowledged = unacknowledged + }, ) init { sync() } @@ -344,9 +386,17 @@ class SessionSuspendControllerTest { private fun sleep(millis: Long) { now += millis - if (pagingOut && gameResidentKb > 1_000_000L) { - gameResidentKb -= 1_000_000L - gameSwapKb += 1_000_000L + if (pagingOut && !handlerReplaced) { + File(control.parentFile, "${control.name}.ack.$GAME").apply { if (!exists()) writeText("") } + } + if (pagingOut && !handlerStalls && !handlerReplaced) { + if (gameResidentKb > 1_000_000L) { + gameResidentKb -= 1_000_000L + gameSwapKb += 1_000_000L + } else { + File(control.parentFile, "${control.name}.ack.$GAME").writeText("1") + pagingOut = false + } } if (root.threads.first().state != 'T' && !continuing) game.threads.forEach { if (it.state != 'Z') { it.state = 'S'; it.cpu++ } diff --git a/tools/linuxfs/preload/zram.c b/tools/linuxfs/preload/zram.c index f5e09cf48..8f31d19eb 100644 --- a/tools/linuxfs/preload/zram.c +++ b/tools/linuxfs/preload/zram.c @@ -126,6 +126,25 @@ static void zr_walk(int compress) { zr_sys(SYS_close, maps); } +// Before it stops a session for suspend, the app waits for .ack. to appear (the handler +// runs here, not one a program installed over it) and then to hold a byte (the page-out finished). +static void zr_ack(int done) { + static const char suffix[] = ".ack."; + char path[sizeof(ZR_CTL) + sizeof(suffix) + 20]; + char digits[20]; + size_t n = 0, d = 0; + for (const char *p = ZR_CTL; *p; p++) path[n++] = *p; + for (const char *p = suffix; *p; p++) path[n++] = *p; + long pid = zr_sys(SYS_getpid); + do digits[d++] = (char)('0' + pid % 10); while ((pid /= 10) > 0); + while (d > 0) path[n++] = digits[--d]; + path[n] = 0; + int fd = (int)zr_sys(SYS_openat, AT_FDCWD, path, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0600); + if (fd < 0) return; + if (done) zr_sys(SYS_write, fd, "1", 1UL); + zr_sys(SYS_close, fd); +} + static void zr_handler(int sig, siginfo_t *info, void *context) { (void)sig; (void)context; @@ -136,7 +155,9 @@ static void zr_handler(int sig, siginfo_t *info, void *context) { && !__atomic_exchange_n(&zr_busy, 1, __ATOMIC_SEQ_CST)) { __atomic_store_n(&zr_pending, 0, __ATOMIC_SEQ_CST); int mode = zr_mode(); + if (mode == 1) zr_ack(0); if (mode >= 0) zr_walk(mode); + if (mode == 1) zr_ack(1); __atomic_store_n(&zr_busy, 0, __ATOMIC_SEQ_CST); } errno = saved; diff --git a/tools/tests/test_zram_preload.py b/tools/tests/test_zram_preload.py index a9a437e3e..88d5c7302 100644 --- a/tools/tests/test_zram_preload.py +++ b/tools/tests/test_zram_preload.py @@ -129,6 +129,8 @@ class ZramPreloadTest(unittest.TestCase): def setUp(self): self.ctl.write_text("0") + for ack in self.dir.glob("ctl.ack.*"): + ack.unlink() def run_program(self, program=None, mb=48, cycles=1, *extra): environment = dict(os.environ, LD_PRELOAD=str(self.lib)) @@ -140,9 +142,10 @@ class ZramPreloadTest(unittest.TestCase): out.setdefault(key, []).append(int(value)) return out - def test_only_the_client_takes_the_signal(self): + def test_every_process_takes_the_signal(self): self.assertEqual([1], self.run_program()["handler"]) - self.assertEqual([0], self.run_program(self.game)["handler"]) + self.assertEqual([1], self.run_program(self.game)["handler"]) + self.assertEqual(2, len(list(self.dir.glob("ctl.ack.*")))) def test_nothing_is_installed_without_the_control_file(self): self.ctl.unlink() @@ -157,6 +160,7 @@ class ZramPreloadTest(unittest.TestCase): self.assertGreater(out["out"][0], 32 * 1024) self.assertLess(out["back"][0], out["out"][0] // 4) self.assertEqual([1], out["intact"]) + self.assertEqual(["1"], [ack.read_text() for ack in self.dir.glob("ctl.ack.*")]) @unittest.skipUnless(swap_total_kb() > 256 * 1024, "needs swap") def test_reads_back_what_the_kernel_swapped_elsewhere(self): From e163b4fdbaca6c586acd389f4192060d9b466a4a Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 22 Oct 2026 10:32:29 -0400 Subject: [PATCH 06/11] Preserve newer sleep requests when a freeze is cancelled --- .../launcher/session/SessionService.kt | 6 +- .../session/SessionSleepRequestTest.kt | 77 +++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/session/SessionSleepRequestTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index ea34327a6..e25bbc821 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -1405,6 +1405,8 @@ class SessionService : Service() { if (shouldSuspend) { freezePending = true freezeCancelled = false + val freezeSteamToken = steamSleepToken + val freezeNativeToken = nativeSleepToken controller.freeze( pageOut = steamSleepToken != null || nativeSleepToken != null || !activityVisible || !screenOn, ) { success -> @@ -1418,8 +1420,8 @@ class SessionService : Service() { releaseLocks() refreshNotification() } else { - completeNativeSleep() - completeSteamSleep() + if (nativeSleepToken == freezeNativeToken) completeNativeSleep() + if (steamSleepToken == freezeSteamToken) completeSteamSleep() if (freezeCancelled) { SessionEvents.record("session.suspend_cancelled") } else { diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionSleepRequestTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionSleepRequestTest.kt new file mode 100644 index 000000000..b136d5fd0 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionSleepRequestTest.kt @@ -0,0 +1,77 @@ +package com.droiddeck.launcher.session + +import android.os.Looper +import java.util.concurrent.AbstractExecutorService +import java.util.concurrent.TimeUnit +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import org.robolectric.util.ReflectionHelpers + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +@LooperMode(LooperMode.Mode.PAUSED) +class SessionSleepRequestTest { + private lateinit var service: SessionService + private val work = ArrayDeque() + private val executor = object : AbstractExecutorService() { + override fun execute(command: Runnable) { work.addLast(command) } + override fun shutdown() {} + override fun shutdownNow(): MutableList = mutableListOf() + override fun isShutdown() = false + override fun isTerminated() = false + override fun awaitTermination(timeout: Long, unit: TimeUnit) = false + } + + @Before fun setUp() { + service = Robolectric.buildService(SessionService::class.java).get() + SessionState.running = true + SessionState.suspended = false + SessionState.mode = SessionService.MODE_RUN + ReflectionHelpers.setField(service, "suspendPolicy", SessionPrefs.SUSPEND_NEVER) + ReflectionHelpers.setField(service, "suspendController", SessionSuspendController( + sessionRoot = { -1 }, helperRoots = { emptyList() }, + suspendAudio = {}, resumeAudio = {}, executor = executor, + )) + } + + @After fun tearDown() { + SessionState.running = false + SessionState.suspended = false + SessionState.mode = SessionService.MODE_STEAM + shadowOf(Looper.getMainLooper()).idle() + work.clear() + } + + @Test fun cancelledFreezePreservesAndRetriesANewerSteamRequest() { + ReflectionHelpers.setField(service, "steamSleepToken", "old-request") + ReflectionHelpers.callInstanceMethod(service, "updateSuspendPolicy") + assertEquals(1, work.size) + ReflectionHelpers.setField(service, "freezeCancelled", true) + ReflectionHelpers.setField(service, "steamSleepToken", "new-request") + work.removeFirst().run() + shadowOf(Looper.getMainLooper()).idle() + assertEquals("new-request", ReflectionHelpers.getField(service, "steamSleepToken")) + assertEquals(1, work.size) + assertFalse(ReflectionHelpers.getField(service, "suspendAttemptFailed")) + } + + @Test fun cancelledFreezeCompletesItsOwnRequestWithoutRetryingIt() { + ReflectionHelpers.setField(service, "steamSleepToken", "old-request") + ReflectionHelpers.callInstanceMethod(service, "updateSuspendPolicy") + ReflectionHelpers.setField(service, "freezeCancelled", true) + work.removeFirst().run() + shadowOf(Looper.getMainLooper()).idle() + assertEquals(null, ReflectionHelpers.getField(service, "steamSleepToken")) + assertEquals(0, work.size) + assertFalse(ReflectionHelpers.getField(service, "suspendAttemptFailed")) + } +} From 2dc2a44115911ef88e088c964cac0abf4cfd2fd2 Mon Sep 17 00:00:00 2001 From: angelrenefm <17342133+angelrenefm@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:35:05 +0200 Subject: [PATCH 07/11] fix: the performance overlay shows the GPU load on Adreno phones whose kgsl busy percentage is unreadable GpuStatsComponent read kgsl's gpubusy once per feed, so the msm reader path (perf_now), read second, got the empty read and showed 0 %. Feeds that share a reader now share one read, and one empty read between gpubusy windows keeps the last load. --- .../launcher/session/GpuStatsComponent.kt | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt index fb6efe09f..08cc2beeb 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt @@ -37,7 +37,7 @@ class GpuStatsComponent(val dir: File) : SessionPart() { feeds.add(Feed(File(dir, "cur_freq"), clock.source, { clock.read()?.times(1_000_000) }, "%-10d\n")) } pick("temp", tempSources(), "%6d\n") { milliCelsius(it) }?.let { feeds.add(it) } - feeds.forEach { feed -> feed.read()?.let { write(feed, it) } } + refresh() Log.i(TAG, "hud: kgsl stats from the session's own: " + MANGOHUD_FILES.joinToString { name -> "$name " + (feeds.firstOrNull { it.file.name == name }?.source ?: "none") }) @@ -55,11 +55,17 @@ class GpuStatsComponent(val dir: File) : SessionPart() { while (running) { try { Thread.sleep(PERIOD_MS) } catch (e: InterruptedException) { break } if (!running) break - for (feed in feeds) feed.read()?.let { write(feed, it) } + refresh() } }, "gpu-stats").apply { isDaemon = true; start() } } + // kgsl's gpubusy can read 0 0 straight after a read, so feeds that share a reader share one read. + private fun refresh() { + val values = HashMap<() -> Long?, Long?>() + for (feed in feeds) values.getOrPut(feed.read) { feed.read() }?.let { write(feed, it) } + } + override fun stop() { running = false thread?.interrupt() @@ -69,11 +75,18 @@ class GpuStatsComponent(val dir: File) : SessionPart() { private fun loadFeed(): Feed? { pick("gpu_busy_percentage", LOAD_SOURCES, "%3d %%\n") { it.coerceIn(0, 100) }?.let { return it } val gpubusy = "$KGSL/gpubusy" + // gpubusy reads 0 0 between its windows, so one empty read keeps the last load; two in a row mean an idle GPU. + var last = 0L + var held = false val read = { runCatching { val parts = File(gpubusy).readText().trim().split(Regex("\\s+")) val total = parts[1].toLong() - if (total > 0) (parts[0].toLong() * 100 / total).coerceIn(0, 100) else 0L + when { + total > 0 -> (parts[0].toLong() * 100 / total).coerceIn(0, 100).also { last = it; held = false } + !held -> last.also { held = true } + else -> 0L + } }.getOrNull() } return if (read() != null) Feed(File(dir, "gpu_busy_percentage"), gpubusy, read, "%3d %%\n") else null From c3f3c04fc5a68d950b2860e3085777d654b53307 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:39:50 -0400 Subject: [PATCH 08/11] fix: latch kgsl GPU busy windows so MangoHud's GPU load stops reading 0% --- .../launcher/session/GpuStatsComponent.kt | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt index fb6efe09f..aadbe8a25 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher.session +import android.os.SystemClock import android.util.Log import com.droiddeck.launcher.core.SessionPart import com.droiddeck.launcher.runtime.LinuxRuntime @@ -23,11 +24,16 @@ class GpuStatsComponent(val dir: File) : SessionPart() { } fun prepare(): Boolean { - if (!File(KGSL).exists() || MANGOHUD_FILES.none { File(KGSL, it).let { f -> f.exists() && !f.canRead() } }) return false + if (!File(KGSL).exists()) return false + val unreadable = MANGOHUD_FILES.any { File(KGSL, it).let { f -> f.exists() && !f.canRead() } } + if (!unreadable && !File(GPUBUSY).canRead()) return false return try { dir.mkdirs() dir.listFiles()?.forEach { java.nio.file.Files.deleteIfExists(it.toPath()) } feeds.clear() + PASSTHROUGH_FILES.forEach { name -> + runCatching { File(dir, name).writeText(File(KGSL, name).readText()) } + } loadFeed()?.let { load -> feeds.add(load) feeds.add(Feed(File(dir, "perf_now"), load.source, load.read, "%-6s\n", percent = true)) @@ -67,16 +73,30 @@ class GpuStatsComponent(val dir: File) : SessionPart() { } private fun loadFeed(): Feed? { - pick("gpu_busy_percentage", LOAD_SOURCES, "%3d %%\n") { it.coerceIn(0, 100) }?.let { return it } - val gpubusy = "$KGSL/gpubusy" - val read = { - runCatching { - val parts = File(gpubusy).readText().trim().split(Regex("\\s+")) - val total = parts[1].toLong() - if (total > 0) (parts[0].toLong() * 100 / total).coerceIn(0, 100) else 0L + latchedBusyFeed()?.let { return it } + return pick("gpu_busy_percentage", LOAD_SOURCES, "%3d %%\n") { it.coerceIn(0, 100) } + } + + // kgsl publishes busy/total once per ~1 s of GPU-on time and zeroes it on any read while the GPU is + // power-collapsed, so direct pollers mostly see 0; latch each published window instead. + private fun latchedBusyFeed(): Feed? { + var load = 0L + var windowAt = 0L + val read = read@{ + val parts = runCatching { + File(GPUBUSY).readText().trim().split(Regex("\\s+")).map { it.toLong() } }.getOrNull() + if (parts == null || parts.size < 2) return@read null + val now = SystemClock.elapsedRealtime() + if (parts[1] > 0) { + load = (parts[0] * 100 / parts[1]).coerceIn(0, 100) + windowAt = now + } else if (now - windowAt > LOAD_HOLD_MS) { + load = 0 + } + load } - return if (read() != null) Feed(File(dir, "gpu_busy_percentage"), gpubusy, read, "%3d %%\n") else null + return if (read() != null) Feed(File(dir, "gpu_busy_percentage"), GPUBUSY, read, "%3d %%\n") else null } private fun pick(name: String, sources: List, format: String, unit: (Long) -> Long?): Feed? { @@ -100,8 +120,11 @@ class GpuStatsComponent(val dir: File) : SessionPart() { const val KGSL = "/sys/class/kgsl/kgsl-3d0" const val MSM_LOAD = "/sys/kernel/debug/dri/0/perf_now" const val MSM_CLOCK = "/sys/devices/platform/soc@0/3d00000.gpu/devfreq/3d00000.gpu/cur_freq" + private const val GPUBUSY = "$KGSL/gpubusy" private const val PERIOD_MS = 500L + private const val LOAD_HOLD_MS = 5_000L private val MANGOHUD_FILES = listOf("gpu_busy_percentage", "clock_mhz", "temp") + private val PASSTHROUGH_FILES = listOf("gpu_model", "gpu_chipid") private const val QCOM_GPU = "/sys/kernel/gpu" private val LOAD_SOURCES = listOf( "$KGSL/gpu_busy_percentage", "$KGSL/devfreq/gpu_load", "$KGSL/gpuload", "$QCOM_GPU/gpu_busy", From 6c22248d77f27bdc975d63349c3b24da656963fc Mon Sep 17 00:00:00 2001 From: angelrenefm <17342133+angelrenefm@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:44:34 +0200 Subject: [PATCH 09/11] fix: feeds that share a reader also share a failed read --- .../java/com/droiddeck/launcher/session/GpuStatsComponent.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt index 08cc2beeb..4cd2b067f 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt @@ -63,7 +63,10 @@ class GpuStatsComponent(val dir: File) : SessionPart() { // kgsl's gpubusy can read 0 0 straight after a read, so feeds that share a reader share one read. private fun refresh() { val values = HashMap<() -> Long?, Long?>() - for (feed in feeds) values.getOrPut(feed.read) { feed.read() }?.let { write(feed, it) } + for (feed in feeds) { + if (feed.read !in values) values[feed.read] = feed.read() + values[feed.read]?.let { write(feed, it) } + } } override fun stop() { From 5461886a82d6f6fdbd33e2af2a0e1e3b99afb7a3 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:54:22 -0400 Subject: [PATCH 10/11] Pin CI 7zip download to verified package mirror --- tools/msitools/packages.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/msitools/packages.txt b/tools/msitools/packages.txt index 69a9dedba..3d9637840 100644 --- a/tools/msitools/packages.txt +++ b/tools/msitools/packages.txt @@ -2,4 +2,4 @@ 4028f44d83b2956ba2931d32aee8cceecbd6481e2febf86a3b6bfed69ef403d5 https://github.com/Droid-Deck/DroidDeck/releases/download/msitools-0.106-r1/libgsf-1.14.60-1-aarch64.pkg.tar.xz 3d7bd103313cb36d6cb703a5e583363e56ff255f105bcc0727916d195967976d https://github.com/Droid-Deck/DroidDeck/releases/download/msitools-0.106-r1/gcab-1.6-2-aarch64.pkg.tar.xz 87fe375a22de1519982234ce99f909ae13c06500784baaa1e8d7a0c0be0d14ee https://github.com/Droid-Deck/DroidDeck/releases/download/msitools-0.106-r1/cabextract-1.11-3-aarch64.pkg.tar.xz -e3bc83ac5960a4363f7ce05eb1d50b03d60d627a4a29f9b02c8e0466a5381b1f http://mirror.archlinuxarm.org/aarch64/extra/7zip-26.04-1-aarch64.pkg.tar.xz +e3bc83ac5960a4363f7ce05eb1d50b03d60d627a4a29f9b02c8e0466a5381b1f https://github.com/Droid-Deck/DroidDeck-Components/releases/download/build-dependency-7zip-26.04-r1/7zip-26.04-1-aarch64.pkg.tar.xz From 58f0f27f391733df81b3cbe7dbca8751b8fbd637 Mon Sep 17 00:00:00 2001 From: The412Banner <205237651+The412Banner@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:30:38 -0400 Subject: [PATCH 11/11] fix(xbox): show why Microsoft sign-in fails, and the gamertag instead of the email (#1322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sign-in sat on a black "Preparing Microsoft sign-in…" screen while the Xbox components downloaded and the helper started, then only said "did not complete" (#1300). The window now names each step (with download progress), and a failure says which step failed and why. Our own messages are shown as written; anything else is named by its exception type only. The line also goes to the stores log, which the shared log zip carries. The Stores page showed the account's sign-in email. It now shows the Xbox gamertag, read with `xodus-cli profile` after the library sync on its own thread (75 s limit: the command allows itself 45 s, plus PRoot start-up), once per signed-in account. Until it is known the email is shown masked, and only the store label while credentials are temporarily unreadable. The account key used for caches and launches is unchanged. Co-authored-by: xXJsonDeruloXx --- .../launcher/stores/StoreAccounts.kt | 14 +++++ .../droiddeck/launcher/stores/StoresState.kt | 2 +- .../launcher/stores/xbox/XboxBackend.kt | 3 +- .../launcher/stores/xbox/XboxGamertag.kt | 51 +++++++++++++++++ .../launcher/stores/xbox/XboxLoginActivity.kt | 57 ++++++++++++++++++- .../droiddeck/launcher/stores/xbox/Xodus.kt | 2 +- .../stores/StoreAccountsCipherTest.kt | 19 +++++++ .../stores/xbox/XboxSignInMessagesTest.kt | 33 +++++++++++ 8 files changed, 175 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxGamertag.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxSignInMessagesTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt index 27bf133f1..a740eb088 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt @@ -94,6 +94,20 @@ object StoreAccounts { return json.optString("display_name", "").ifEmpty { json.optString("username", "") }.ifEmpty { store.label } } + /** The name the Stores page shows. Xbox shows the gamertag, else the sign-in email with its name part masked. */ + fun displayName(context: Context, store: Store): String? { + if (store != Store.XBOX) return signedInAs(context, store) + val json = read(context, store) ?: return if (isUnavailable(store)) store.label else null + val signedIn = json.optString("username").takeIf { it.isNotBlank() } ?: return null + json.optString("gamertag").takeIf { it.isNotBlank() }?.let { return it } + return maskEmail(signedIn) + } + + internal fun maskEmail(name: String): String { + val at = name.indexOf('@') + return if (at <= 0) name.take(1) + "•••" else name.take(1) + "•••" + name.substring(at) + } + private fun seal(f: File, json: JSONObject, retry: Boolean = false): File? { if (keystoreFailed && !retry) return null val text = json.toString() diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt index e780733d9..a28b0e8a4 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt @@ -79,7 +79,7 @@ object StoresState { val app = context.applicationContext com.droiddeck.launcher.stores.download.SavedQueue.restore(app) background("stores-refresh") { - val found = Store.entries.mapNotNull { store -> StoreAccounts.signedInAs(app, store)?.let { store to it } } + val found = Store.entries.mapNotNull { store -> StoreAccounts.displayName(app, store)?.let { store to it } } val onDisk = StoreInstallRoot.gameFolders(app).mapNotNull { folder -> StoreGameSidecar.read(folder)?.takeIf { it.isInstalled }?.let { InstalledStoreGame(it, folder) } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt index 24d7ff622..9121a35c9 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt @@ -53,7 +53,7 @@ object XboxBackend : StoreBackend { else StoresState.status.remove(store) } } - if (cached != null && !cached.needsRefresh(force)) return@Thread + if (cached != null && !cached.needsRefresh(force)) { XboxGamertag.ensure(app); return@Thread } val response = Xodus.run(app, listOf("library", "--json", "--market", market)) val licenses = XboxCatalog.access(response, account, market, report = { StoresState.logLine("Xbox: $it") }) val products = XboxCatalog.rawProducts(licenses.keys.toList(), market) @@ -73,6 +73,7 @@ object XboxBackend : StoreBackend { StoresState.problems.remove(store) } } + if (current == generation) XboxGamertag.ensure(app) } catch (e: Exception) { StoresState.post { if (current == generation) { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxGamertag.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxGamertag.kt new file mode 100644 index 000000000..b065e8baf --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxGamertag.kt @@ -0,0 +1,51 @@ +package com.droiddeck.launcher.stores.xbox + +import android.content.Context +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreAccounts +import com.droiddeck.launcher.stores.StoresState +/** Keeps the account's Xbox gamertag in its record so the Stores page can show it instead of the sign-in email. */ +internal object XboxGamertag { + /** `xodus-cli profile` allows itself 45 s, and starting it through PRoot adds to that. */ + private const val TIMEOUT_SECONDS = 75L + private val triedGeneration = java.util.concurrent.atomic.AtomicReference(null) + + internal fun valid(value: String): Boolean = value.isNotEmpty() && value.length <= 64 && value.none { it.isISOControl() } + + /** Reads the gamertag with `xodus-cli profile` and stores it; false when Xbox didn't return one. */ + fun refresh(app: Context, credentialGeneration: String? = null): Boolean = try { + val gamertag = Xodus.run(app, listOf("profile", "--json"), timeout = TIMEOUT_SECONDS, credentialGeneration = credentialGeneration) + .optString("gamertag").trim() + require(valid(gamertag)) + XboxCredentials.lifecycle(app, credentialGeneration) { + val record = StoreAccounts.read(app, Store.XBOX) ?: error("Xbox credentials missing") + record.put("gamertag", gamertag) + StoreAccounts.write(app, Store.XBOX, record) + } + StoresState.logLine("Xbox: gamertag read") + true + } catch (e: Exception) { + StoresState.logLine("Xbox: gamertag unavailable (${XboxLoginActivity.failure(e)})") + false + } + + /** Fetches a missing gamertag once per signed-in account, on its own thread so it never holds up a library sync. */ + fun ensure(app: Context) { + Thread({ + runCatching { + val generation = XboxCredentials.lifecycle(app) { it } + if (!StoreAccounts.read(app, Store.XBOX)?.optString("gamertag").isNullOrBlank()) return@runCatching + if (!shouldTryRefresh(generation)) return@runCatching + if (refresh(app, generation)) StoresState.refresh(app) + }.onFailure { StoresState.logLine("Xbox: gamertag check failed (${it.javaClass.simpleName})") } + }, "xbox-gamertag").start() + } + + internal fun shouldTryRefresh(generation: String): Boolean { + while (true) { + val previous = triedGeneration.get() + if (previous == generation) return false + if (triedGeneration.compareAndSet(previous, generation)) return true + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt index 01e19158f..218ab26ff 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.graphics.Color import android.net.Uri import android.os.Bundle +import android.util.Log import android.webkit.CookieManager import android.webkit.WebResourceRequest import android.webkit.WebView @@ -14,8 +15,11 @@ import android.widget.TextView import androidx.activity.ComponentActivity import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature +import com.droiddeck.launcher.core.AddonPack import com.droiddeck.launcher.stores.Store import com.droiddeck.launcher.stores.StoreAccounts +import com.droiddeck.launcher.stores.StoreLogFiles +import com.droiddeck.launcher.stores.StoreLog import org.json.JSONObject import java.util.concurrent.TimeUnit @@ -26,6 +30,7 @@ class XboxLoginActivity : ComponentActivity() { @Volatile private var child: Xodus.Child? = null @Volatile private var session = 0L @Volatile private var closed = false + @Volatile private var step = "starting" @SuppressLint("SetJavaScriptEnabled") override fun onCreate(savedInstanceState: Bundle?) { @@ -49,10 +54,17 @@ class XboxLoginActivity : ComponentActivity() { if (closed) return@Thread try { val accountGeneration = XboxCredentials.lifecycle(applicationContext) { it } + progress("downloading the Xbox components", "Downloading Xbox components…") + check(AddonPack.XBOX.await(applicationContext, { !closed }) { state -> + if (state is AddonPack.State.Downloading) show("Downloading Xbox components… ${(state.progress * 100).toInt()}%") + }) { "Couldn't download the Xbox components. Check your connection and try again." } + if (closed) return@Thread + progress("starting the Xbox helper", "Starting the Xbox helper…") Xodus.start(applicationContext, listOf("--external-webview-stdio", "login"), credentialGeneration = accountGeneration).use { process -> child = process if (closed) return@use Xodus.diagnostics(process) + progress("waiting for the Xbox helper", "Waiting for the Xbox helper…") val timer = Thread({ runCatching { if (!process.process.waitFor(10, TimeUnit.MINUTES)) process.process.destroy() } }, "xbox-login-timeout").apply { start() } @@ -68,16 +80,19 @@ class XboxLoginActivity : ComponentActivity() { check(!ready && frame.getInt("version") == 1) ready = true process.send(JSONObject().put("type", "hello").put("version", 1)) + progress("waiting for Microsoft's sign-in page", "Opening Microsoft sign-in…") } "open" -> { val next = frame.getLong("session") check(ready && next > latest && next <= 32 && trusted(frame.getString("url"))) latest = next; session = next + step = "loading Microsoft's sign-in page" runOnUiThread { if (!closed) open(frame) } } "close" -> { check(frame.getLong("session") == session) session = 0L + step = "completing the sign-in" runOnUiThread { disposeView(); status.text = "Completing Microsoft sign-in…" } } "context" -> { @@ -95,26 +110,53 @@ class XboxLoginActivity : ComponentActivity() { else -> error("Unsupported Xbox sign-in response") } } - check(process.process.waitFor(5, TimeUnit.SECONDS) && process.process.exitValue() == 0 && ready && latest > 0 && session == 0L) + if (closed) return@use + check(process.process.waitFor(5, TimeUnit.SECONDS)) { "the Xbox helper did not exit" } + val exit = process.process.exitValue() + check(exit == 0) { "the Xbox helper stopped with exit code $exit" } + check(ready) { "the Xbox helper never became ready" } + check(latest > 0) { "Microsoft's sign-in page was never requested" } + check(session == 0L) { "the sign-in page was closed before Microsoft finished" } timer.interrupt() } child = null if (closed) return@Thread + step = "saving the account" val account = Xodus.run(applicationContext, listOf("account", "--json"), credentialGeneration = accountGeneration) - check(account.optBoolean("stored")) + check(account.optBoolean("stored")) { "Xbox did not save the account" } XboxCredentials.lifecycle(this, accountGeneration) { val record = StoreAccounts.read(this, Store.XBOX) ?: error("Xbox credentials missing") record.put("username", account.getString("username")) StoreAccounts.write(this, Store.XBOX, record) } + logStep("signed in") runOnUiThread { finish() } } catch (e: Exception) { child = null - if (!closed) runOnUiThread { disposeView(); status.text = "Microsoft sign-in did not complete. Close this window and try again." } + if (closed) return@Thread + val failed = step + val reason = failure(e) + logStep("failed while $failed: $reason") + runOnUiThread { + disposeView() + status.text = "Microsoft sign-in did not complete.\nFailed while $failed: $reason\nClose this window and try again." + } } }, "xbox-login").start() } + private fun show(text: String) = runOnUiThread { if (!closed && view == null) status.text = text } + + private fun progress(name: String, text: String) { step = name; show(text) } + + /** Written to the stores log, which the shared log zip carries; the main process's log helpers don't run in this process. */ + private fun logStep(line: String) { + val text = StoreLog.redactLine("Xbox sign-in: $line") + Log.i(TAG, text) + val time = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", java.util.Locale.US).format(java.util.Date()) + StoreLogFiles.append(applicationContext, "$time $text") + } + @SuppressLint("SetJavaScriptEnabled") private fun open(frame: JSONObject) { disposeView() @@ -172,6 +214,15 @@ class XboxLoginActivity : ComponentActivity() { } companion object { + private const val TAG = "XboxLogin" + + /** Our own check messages are safe to show; anything else is named by its type, never its text, which may hold sign-in data. */ + internal fun failure(e: Exception): String { + val message = e.message.orEmpty() + return if (e is IllegalStateException && message.isNotBlank() && message != "Check failed." && message.length <= 160) message + else e.javaClass.simpleName.ifBlank { "error" } + } + private val hosts = setOf("login.live.com", "account.live.com", "login.microsoftonline.com", "login.microsoft.com", "account.microsoft.com", "sisu.xboxlive.com") private val origins = hosts.map { "https://$it" }.toSet() private val allowedHeaders = setOf("cxh-capabilities", "cxh-correlationid", "cxh-msabinaryversion", "cxh-identityclientbinaryversion", "cxh-osversioninfo", "cxh-platform", "cxh-protocol", "cxh-source", "hostapp") diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt index 6e7c30df8..cf7686e0d 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt @@ -136,7 +136,7 @@ internal object Xodus { fun diagnostics(child: Child): Thread = Thread({ runCatching { child.process.errorStream.bufferedReader().useLines { lines -> lines.forEach { line -> - if (line.matches(Regex("NEXUS_CREDENTIAL_ERROR_V1 [a-z-]{1,48}|NEXUS_LIBRARY_DIAGNOSTIC_V1 [a-z-]{1,48} [0-9]{1,3} [0-9]{1,6}|NEXUS_SESSION_DIAGNOSTIC_V1 [a-z-]{1,64}|Account library unavailable \\([a-z-]{1,64}\\); credentials and installed games are unchanged\\.|Account library timed out; installed games are unchanged\\."))) + if (line.matches(Regex("NEXUS_CREDENTIAL_ERROR_V1 [a-z-]{1,48}|NEXUS_LIBRARY_DIAGNOSTIC_V1 [a-z-]{1,48} [0-9]{1,3} [0-9]{1,6}|NEXUS_SESSION_DIAGNOSTIC_V1 [a-z-]{1,64}|Account library unavailable \\([a-z-]{1,64}\\); credentials and installed games are unchanged\\.|Account library timed out; installed games are unchanged\\.|Xbox profile is temporarily unavailable \\([a-z-]{1,48}\\); stored account is unchanged\\.|Xbox profile request timed out; stored account is unchanged\\."))) com.droiddeck.launcher.stores.StoresState.logLine("Xbox: $line") } } } diff --git a/app/src/test/java/com/droiddeck/launcher/stores/StoreAccountsCipherTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/StoreAccountsCipherTest.kt index e0c657a14..2dde17cab 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/StoreAccountsCipherTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/StoreAccountsCipherTest.kt @@ -131,6 +131,25 @@ class StoreAccountsCipherTest { assertEquals("test@example.test", StoreAccounts.signedInAs(app, Store.XBOX)) } + @Test fun xboxShowsTheGamertagElseAMaskedEmail() { + StoreAccounts.write(app, Store.XBOX, JSONObject().put("username", "player@example.test")) + assertEquals("p•••@example.test", StoreAccounts.displayName(app, Store.XBOX)) + StoreAccounts.write(app, Store.XBOX, JSONObject().put("username", "player@example.test").put("gamertag", "Some Gamertag")) + assertEquals("Some Gamertag", StoreAccounts.displayName(app, Store.XBOX)) + assertEquals("player@example.test", StoreAccounts.signedInAs(app, Store.XBOX)) + StoreAccounts.write(app, Store.EPIC, creds) + assertEquals("Someone", StoreAccounts.displayName(app, Store.EPIC)) + assertEquals("n•••", StoreAccounts.maskEmail("nickname")) + } + + @Test fun xboxDisplayNameDoesNotRevealEmailWhenCredentialsAreTemporarilyUnavailable() { + StoreAccounts.write(app, Store.XBOX, JSONObject().put("username", "player@example.test")) + StoreAccounts.keys = Flaky(key, 1) { java.security.ProviderException("busy") } + assertEquals(Store.XBOX.label, StoreAccounts.displayName(app, Store.XBOX)) + assertTrue(StoreAccounts.isUnavailable(Store.XBOX)) + assertTrue(file(Store.XBOX).exists()) + } + @Test fun signOutRemovesTheFile() { StoreAccounts.write(app, Store.GOG, creds) StoreAccounts.clear(app, Store.GOG) diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxSignInMessagesTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxSignInMessagesTest.kt new file mode 100644 index 000000000..7726ca42f --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxSignInMessagesTest.kt @@ -0,0 +1,33 @@ +package com.droiddeck.launcher.stores.xbox + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +@org.junit.runner.RunWith(org.robolectric.RobolectricTestRunner::class) +@org.robolectric.annotation.Config(sdk = [33], application = android.app.Application::class) +class XboxSignInMessagesTest { + @Test fun ownCheckMessagesAreShownAndOtherTextIsNot() { + assertEquals("the Xbox helper stopped with exit code 3", XboxLoginActivity.failure(IllegalStateException("the Xbox helper stopped with exit code 3"))) + assertEquals("IllegalStateException", XboxLoginActivity.failure(IllegalStateException("Check failed."))) + assertEquals("JSONException", XboxLoginActivity.failure(org.json.JSONException("token=secret-value"))) + assertEquals("IllegalArgumentException", XboxLoginActivity.failure(IllegalArgumentException("secret-value"))) + } + + @Test fun gamertagMustBeShortPrintableText() { + assertTrue(XboxGamertag.valid("Some Gamertag")) + assertFalse(XboxGamertag.valid("")) + assertFalse(XboxGamertag.valid("a\nb")) + assertFalse(XboxGamertag.valid("x".repeat(65))) + } + + @Test fun gamertagRefreshIsOncePerAccountGeneration() { + val first = "account-generation-one-${System.nanoTime()}" + val second = "account-generation-two-${System.nanoTime()}" + assertTrue(XboxGamertag.shouldTryRefresh(first)) + assertFalse(XboxGamertag.shouldTryRefresh(first)) + assertTrue(XboxGamertag.shouldTryRefresh(second)) + assertFalse(XboxGamertag.shouldTryRefresh(second)) + } +}