diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index abc1eee8f..7d7dcf066 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 && @@ -1686,7 +1682,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 }, onUnfoldedControlsWithController = { on -> @@ -1918,9 +1913,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 @@ -2037,7 +2029,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/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/frontend/AddedGameArt.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGameArt.kt index 62d04d247..8315c22e9 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/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/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/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt index fb6efe09f..006350916 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)) @@ -37,7 +43,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 +61,20 @@ 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) { + if (feed.read !in values) values[feed.read] = feed.read() + values[feed.read]?.let { write(feed, it) } + } + } + override fun stop() { running = false thread?.interrupt() @@ -67,16 +82,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 +129,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", 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 07a3c9f9e..d9d2d8e1d 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 @@ -1051,15 +1050,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 55e1adb44..5cb8fede7 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -98,6 +98,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). @@ -322,6 +324,7 @@ class SessionService : Service() { steamSleepToken = null completeNativeSleep() suspendOperationPending = false + freezePending = false suspendAttemptFailed = false suspendController = null SessionState.suspended = false @@ -616,6 +619,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, 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, "unacknowledged" to unacknowledged, "residentMb" to residentKb / 1024, + "swapMb" to swapKb / 1024, "swapBeforeMb" to swapBeforeKb / 1024, "ms" to millis, + )) + } + }, ) mainHandler.post { updateSuspendPolicy() } updateDownloadMonitoring() @@ -696,8 +710,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 "")) @@ -1413,6 +1426,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 @@ -1423,20 +1440,31 @@ class SessionService : Service() { "nativeSleep" to (nativeSleepToken != null), )) if (shouldSuspend) { - controller.freeze { success -> + freezePending = true + freezeCancelled = false + val freezeSteamToken = steamSleepToken + val freezeNativeToken = nativeSleepToken + 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") releaseLocks() refreshNotification() } else { - completeNativeSleep() - completeSteamSleep() - suspendAttemptFailed = true - Log.w(TAG, "could not confirm that the session stopped") + if (nativeSleepToken == freezeNativeToken) completeNativeSleep() + if (steamSleepToken == freezeSteamToken) completeSteamSleep() + if (freezeCancelled) { + SessionEvents.record("session.suspend_cancelled") + } else { + suspendAttemptFailed = true + Log.w(TAG, "could not confirm that the session stopped") + } } updateSuspendPolicy() } @@ -1607,6 +1635,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 d694d6786..5d0e32f99 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,9 @@ 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, unacknowledged: Int, residentKb: Long, swapKb: Long, swapBeforeKb: Long, millis: Long) -> Unit = + { _, _, _, _, _, _ -> }, ) { private enum class Role { SESSION_ROOT, SESSION_CHILD, HELPER_ROOT, HELPER_CHILD } @@ -38,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(completion: (Boolean) -> Unit) { + fun freeze(pageOut: Boolean = false, completion: (Boolean) -> Unit) { + cancelled = false submit({ val records = frozen.associateByTo(LinkedHashMap()) { it.pid to it.started } var complete = false @@ -59,17 +64,18 @@ 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) { - 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 @@ -97,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 @@ -233,6 +244,65 @@ 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 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) + // 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 unacknowledged = targets.count { acks.getValue(it).length() == 0L && !isGone(it) } + acks.values.forEach { it.delete() } + val after = memoryKb(targets) + 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 + 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 +380,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 = 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 const val FREEZE_MAX_PASSES = 256 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/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 debaf4ad0..cfb0ed53d 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt @@ -23,6 +23,7 @@ data class CatalogItem( val mature: Boolean = false, val subscription: Boolean = false, val includedWith: String = "", + val logoUrl: String? = null, ) { val accessible: Boolean get() = owned || subscription /** Licensed now, or a supported package the account's subscription can install. */ 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/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/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 5a5eae4fa..a9a25759e 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("subscription", i.subscription) 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), subscription = o.optBoolean("subscription"), 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 8d40d002b..3be966b96 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 @@ -133,7 +133,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 1ba78d795..acb7333e8 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 @@ -11,10 +11,12 @@ import com.droiddeck.launcher.stores.StoreInstallRoot import com.droiddeck.launcher.stores.StoreInstalls import com.droiddeck.launcher.stores.StoreLanguage 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 @@ -85,6 +87,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) @@ -118,6 +127,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()) + mapOf(StoreLanguage.EXTRA to installedLanguage), 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/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt index dcb5eece6..c7b78ee05 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt @@ -27,6 +27,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 @@ -341,11 +342,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/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index 778673e92..a444f3711 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?, @@ -121,7 +119,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 = {}, @@ -333,12 +330,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 19247926d..c7528035f 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 09a8fc26b..d6a7aa64e 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 e9a4965db..e5ec507d7 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 c81ac80cf..7ff26b087 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 1db4ad98b..dbe459497 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 f34addfdd..73fa6ae85 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2536,9 +2536,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 a3ff804a1..097d18c2b 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 de887ccc2..edbccd44d 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2571,9 +2571,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 b211e761f..2d8d8a12b 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 063e386b6..b268e9efd 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2571,9 +2571,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 c5a153b80..d08626be6 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 2fb0b46f9..6be57ffd2 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 57ad59eaf..776885a84 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 d73c4c26c..d5fde44e5 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2561,9 +2561,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 4d92197b5..962a16946 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 bf37467ab..7d58f7a91 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 4f686f2c7..3309b0ce8 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 755727833..67ba109eb 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 5e853ee97..183254ed4 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 58a0a6d77..92c261d45 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 9e32e4eae..c5dc17cb0 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -2569,9 +2569,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 239570374..a94b36b00 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -2561,9 +2561,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 cc52acb0f..1fdb69d8e 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 6fea03864..c782aae6b 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2596,9 +2596,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 e72854d6e..c6659abfd 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 f707b5968..7dd606217 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 ffa9537d9..5422b926c 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -2538,9 +2538,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 3f4fd9a07..e1753cba3 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 78abffb95..91bf30f13 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 f9c19dfc0..e4a6c6933 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 10f08c154..72cc6cdda 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 0083ac821..19d11a7e8 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 5ca139d36..cf80262af 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/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/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/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()) + } +} 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) + } +} 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")) + } +} 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..02b38143e 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,66 @@ 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) + 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() { + 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 +313,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 +336,23 @@ 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 + 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, unacknowledged, resident, swap, _, _ -> + pagedOut += Triple(processes, resident, swap) + this.unacknowledged = unacknowledged + }, ) init { sync() } @@ -293,6 +366,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 +386,18 @@ class SessionSuspendControllerTest { private fun sleep(millis: Long) { now += millis + 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++ } } @@ -326,7 +413,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 +466,6 @@ class SessionSuspendControllerTest { const val GAME = 200 const val STOP = 19 const val CONT = 18 + const val URG = 23 } } 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/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() 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)) + } +} diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 236ff8598..688d0c19b 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..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; @@ -148,8 +169,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"); 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 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):