Merge branch 'main' into Test

This commit is contained in:
MaxsTechReview authored and GitHub committed 2026-10-22 14:52:13 -04:00
commit d9c03369a2
78 files changed
+1095 -249

No files matched your search

@@ -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<Boolean?>(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)
@@ -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()
@@ -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<AddedGames.Game>): Boolean {
internal class Sources(
val storeLogo: (AddedGames.Game) -> Boolean,
val steamSearch: (String) -> Int?,
val steam: (String, File, Set<Slot>) -> Boolean,
val sgdbGame: (String, Int?, String) -> Int?,
val sgdb: (String, Int, File, Set<Slot>) -> 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<Slot> {
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<AddedGames.Game>): Boolean =
fetchMissing(context, games, SteamGridDb.key(context), network)
internal fun fetchMissing(context: Context, games: List<AddedGames.Game>, 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<Slot>): 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
@@ -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,
)
}
@@ -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)
@@ -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,
)
}
@@ -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> = 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")
@@ -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<String>, 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",
@@ -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", "") ?: ""
@@ -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()
@@ -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<ProcessRecord>()
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<TaskIdentity, Long>? = 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<Pair<Int, Long>, ProcessRecord>, root: Int, helpers: List<Int>) {
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<ProcessRecord>): Pair<Long, Long> {
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
@@ -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()
@@ -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
@@ -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. */
@@ -22,6 +22,7 @@ class StoreGameSidecar(
val epic: EpicOptions = EpicOptions(),
val cloud: Boolean = true,
val achievements: Boolean = true,
val logo: String? = null,
) {
val isInstalled: Boolean get() = state == STATE_INSTALLED
@@ -33,7 +34,8 @@ class StoreGameSidecar(
env: Map<String, String> = this.env, installVersion: String = this.installVersion,
installedAt: Long = this.installedAt, cover: String? = this.cover, hero: String? = this.hero,
extra: Map<String, String> = this.extra, state: String = this.state, epic: EpicOptions = this.epic, cloud: Boolean = this.cloud, achievements: Boolean = this.achievements,
) = StoreGameSidecar(store, id, title, exe, launcher, args, env, installVersion, installedAt, cover, hero, extra, state, epic, cloud, achievements)
logo: String? = this.logo,
) = StoreGameSidecar(store, id, title, exe, launcher, args, env, installVersion, installedAt, cover, hero, extra, state, epic, cloud, achievements, logo)
fun toJson(): JSONObject = JSONObject().apply {
put("version", VERSION)
@@ -52,6 +54,7 @@ class StoreGameSidecar(
if (installedAt > 0L) put("installedAt", installedAt)
if (!cover.isNullOrEmpty()) put("cover", cover)
if (!hero.isNullOrEmpty()) put("hero", hero)
if (!logo.isNullOrEmpty()) put("logo", logo)
if (extra.isNotEmpty()) put("extra", JSONObject(extra as Map<*, *>))
}
@@ -129,6 +132,7 @@ class StoreGameSidecar(
epic = o.optJSONObject("epic")?.let { e -> EpicOptions(eos = e.optBoolean("eos", true), offline = e.optBoolean("offline", false)) } ?: EpicOptions(),
cloud = o.optBoolean("cloud", true),
achievements = o.optBoolean("achievements", true),
logo = o.optString("logo", "").ifEmpty { null },
)
}
@@ -40,7 +40,7 @@ object StoreInstalls {
val app = context.applicationContext
onStep?.invoke(0, STEPS)
val before = StoreGameSidecar.read(folder)
val finished = StoreLaunch.writeLauncher(folder, sidecar.copy(state = StoreGameSidecar.STATE_INSTALLED, epic = before?.epic ?: sidecar.epic, cloud = before?.cloud ?: sidecar.cloud, achievements = before?.achievements ?: sidecar.achievements))
val finished = StoreLaunch.writeLauncher(folder, sidecar.copy(state = StoreGameSidecar.STATE_INSTALLED, epic = before?.epic ?: sidecar.epic, cloud = before?.cloud ?: sidecar.cloud, achievements = before?.achievements ?: sidecar.achievements, logo = sidecar.logo ?: before?.logo))
finished.write(folder)
if (StoreGameSidecar.read(folder)?.isInstalled != true) throw java.io.IOException("the install record could not be written in ${folder.name}")
onStep?.invoke(1, STEPS)
@@ -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) }
}
@@ -124,6 +124,7 @@ internal class AmazonInstaller(
installVersion = versionId, installedAt = System.currentTimeMillis(),
cover = cover, hero = item.imageUrl,
extra = mapOf("entitlementId" to entitlementId, "sku" to sku),
logo = item.logoUrl,
)
StoreInstalls.complete(app, folder, sidecar, onStep = { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) })
return folder.path
@@ -24,6 +24,7 @@ data class AmazonGame(
val publisher: String = "",
val productSku: String = "",
val versionId: String = "",
val logoUrl: String = "",
)
object AmazonLibrary {
@@ -42,7 +43,8 @@ object AmazonLibrary {
JSONObject()
.put("productId", g.productId).put("entitlementId", g.entitlementId).put("title", g.title)
.put("artUrl", g.artUrl).put("heroUrl", g.heroUrl).put("developer", g.developer)
.put("publisher", g.publisher).put("productSku", g.productSku).put("versionId", g.versionId),
.put("publisher", g.publisher).put("productSku", g.productSku).put("versionId", g.versionId)
.apply { if (g.logoUrl.isNotEmpty()) put("logoUrl", g.logoUrl) },
)
}.toString()
@@ -60,6 +62,7 @@ object AmazonLibrary {
publisher = o.optString("publisher", ""),
productSku = o.optString("productSku", ""),
versionId = o.optString("versionId", ""),
logoUrl = o.optString("logoUrl", ""),
)
}
return tidy(games)
@@ -94,6 +97,7 @@ object AmazonLibrary {
sizeBytes = prefs.getLong("size_${game.productId}", 0L).takeIf { it > 0 } ?: 0L,
storeUrl = STORE_URL,
extra = mapOf("entitlementId" to game.entitlementId, "sku" to game.productSku, "version" to ""),
logoUrl = game.logoUrl.ifEmpty { null },
)
}
@@ -106,7 +110,7 @@ object AmazonLibrary {
}
}
private fun gameFrom(o: JSONObject, before: AmazonGame?): AmazonGame = AmazonGame(
internal fun gameFrom(o: JSONObject, before: AmazonGame?): AmazonGame = AmazonGame(
productId = o.optString("productId"),
entitlementId = o.optString("entitlementId"),
title = o.optString("title"),
@@ -116,6 +120,7 @@ object AmazonLibrary {
publisher = o.optString("publisher"),
productSku = o.optString("sku"),
versionId = before?.versionId.orEmpty(),
logoUrl = o.optString("logo").ifEmpty { before?.logoUrl.orEmpty() },
)
private fun backfillPosters(app: Context, games: List<AmazonGame>) {
@@ -77,6 +77,7 @@ object SavedQueue {
private fun itemJson(i: CatalogItem) = JSONObject().apply {
put("store", i.store.id); put("id", i.id); put("title", i.title)
put("image", i.imageUrl ?: ""); put("tall", i.tallImageUrl ?: "")
i.logoUrl?.let { put("logo", it) }
put("tags", i.tags); put("developer", i.developer); put("description", i.description)
put("size", i.sizeBytes); put("owned", i.owned); put("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 },
)
}
}
@@ -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)
@@ -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()),
)
}
@@ -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),
@@ -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) {
@@ -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<String?>(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
}
}
}
@@ -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")
@@ -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")
} }
}
@@ -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
@@ -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(
-3
View File
@@ -287,9 +287,6 @@
<string name="mode_suspend_steam_note">«تلقائي» يوقف مؤقتًا ويستأنف عند الظهور. «أصلي» يجهّز Steam للسكون أولًا. «يدوي» ينتظر الضغط على «استئناف». «أبدًا» يُبقيه قيد التشغيل.</string>
<string name="mode_background_downloads">مواصلة التنزيلات</string>
<string name="mode_background_downloads_hint">تأخير التعليق في الخلفية أثناء قيام Steam بالتنزيل.</string>
<string name="mode_zram">ضغط zRAM</string>
<string name="mode_zram_hint">أثناء تشغيل اللعبة يتم ضغط ذاكرة عميل Steam إلى zram في الجهاز، فتحصل اللعبة على ذاكرة RAM أكبر. يسري عند تشغيل Steam في المرة القادمة.</string>
<string name="mode_zram_unsupported">غير متاح: لا يحتوي هذا الجهاز على zram أو swap، أو أن النواة أقدم من 5.4.</string>
<string name="mode_suspend_note">«تلقائي» يوقف مؤقتًا في الخلفية ويستأنف عند الظهور. «يدوي» ينتظر الضغط على «استئناف». «أبدًا» يُبقيه قيد التشغيل.</string>
<string name="mode_startup">بدء التشغيل</string>
<string name="mode_steam_startup">تشغيل Steam مع DroidDeck</string>
@@ -287,9 +287,6 @@
<string name="mode_suspend_steam_note">Auto pausa y reanuda al mostrarse. Nativo prepara Steam para la suspensión primero. Manual espera a Reanudar. Nunca sigue ejecutándose.</string>
<string name="mode_background_downloads">Mantener las descargas activas</string>
<string name="mode_background_downloads_hint">Retrasa la suspensión en segundo plano mientras Steam esté descargando.</string>
<string name="mode_zram">Compresión zRAM</string>
<string name="mode_zram_hint">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.</string>
<string name="mode_zram_unsupported">No disponible: este dispositivo no tiene swap activo, o su kernel es anterior a la 5.4.</string>
<string name="mode_suspend_note">Auto pausa en segundo plano y reanuda al mostrarse. Manual espera a Reanudar. Nunca sigue ejecutándose.</string>
<string name="mode_startup">Inicio</string>
<string name="mode_steam_startup">Iniciar Steam con DroidDeck</string>
-3
View File
@@ -287,9 +287,6 @@
<string name="mode_suspend_steam_note">Автоматично поставя на пауза и продължава, когато се покаже отново. Системно подготвя Steam за заспиване първо. Ръчно изчаква „Продължи“. Никога продължава да работи.</string>
<string name="mode_background_downloads">Продължаване на изтеглянията</string>
<string name="mode_background_downloads_hint">Отлага фоновото спиране, докато Steam активно изтегля.</string>
<string name="mode_zram">zRAM компресия</string>
<string name="mode_zram_hint">Докато играта работи, паметта на Steam клиента се компресира в zram на устройството, така че играта получава повече RAM. Важи при следващото стартиране на Steam.</string>
<string name="mode_zram_unsupported">Недостъпно: устройството няма zram или swap, или ядрото е по-старо от 5.4.</string>
<string name="mode_suspend_note">Автоматично поставя на пауза във фонов режим и продължава при показване. Ръчно изчаква „Продължи“. Никога продължава да работи.</string>
<string name="mode_startup">Стартиране</string>
<string name="mode_steam_startup">Стартиране на Steam заедно с DroidDeck</string>
-3
View File
@@ -287,9 +287,6 @@
<string name="mode_suspend_steam_note">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.</string>
<string name="mode_background_downloads">Nechat stahování běžet</string>
<string name="mode_background_downloads_hint">Oddálí pozastavení na pozadí, pokud Steam aktivně stahuje.</string>
<string name="mode_zram">Komprese zRAM</string>
<string name="mode_zram_hint">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.</string>
<string name="mode_zram_unsupported">Není k dispozici: zařízení nemá zram ani swap, nebo je jádro starší než 5.4.</string>
<string name="mode_suspend_note">Automaticky pozastaví na pozadí a obnoví při zobrazení. Manuálně čeká na Obnovit. Nikdy běh nezastavuje.</string>
<string name="mode_startup">Spouštění</string>
<string name="mode_steam_startup">Spustit Steam s DroidDeck</string>
-3
View File
@@ -287,9 +287,6 @@
<string name="mode_suspend_steam_note">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.</string>
<string name="mode_background_downloads">Fortsæt downloads</string>
<string name="mode_background_downloads_hint">Udskyder baggrundspause, mens Steam aktivt downloader.</string>
<string name="mode_zram">zRAM-komprimering</string>
<string name="mode_zram_hint">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.</string>
<string name="mode_zram_unsupported">Ikke tilgængeligt: enheden har ingen zram eller swap, eller kernen er ældre end 5.4.</string>
<string name="mode_suspend_note">Auto sætter på pause i baggrunden og genoptager, når den vises. Manuel venter på Genoptag. Aldrig fortsætter med at køre.</string>
<string name="mode_startup">Opstart</string>
<string name="mode_steam_startup">Start Steam med DroidDeck</string>
-3
View File
@@ -2536,9 +2536,6 @@
<string name="stores_hltb_completionist">Komplettierer</string>
<string name="stores_hltb_hours">%1$s Std.</string>
<string name="stores_hltb_minutes">%1$d Min.</string>
<string name="mode_zram">zRAM-Komprimierung</string>
<string name="mode_zram_hint">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.</string>
<string name="mode_zram_unsupported">Nicht verfügbar: Dieses Gerät hat kein zram oder keinen Swap, oder sein Kernel ist älter als 5.4.</string>
<string name="setup_reset_settings">Alle Einstellungen zurücksetzen</string>
<string name="setup_reset_settings_title">Alle Einstellungen zurücksetzen?</string>
<string name="setup_reset_settings_body">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.</string>
-3
View File
@@ -287,9 +287,6 @@
<string name="mode_suspend_steam_note">Το Αυτόματο θέτει σε παύση και συνεχίζει όταν εμφανίζεται. Το Εγγενής προετοιμάζει πρώτα το Steam για αδρανοποίηση. Το Μη αυτόματο περιμένει το Συνέχιση. Το Ποτέ συνεχίζει να εκτελείται.</string>
<string name="mode_background_downloads">Διατήρηση των λήψεων σε εξέλιξη</string>
<string name="mode_background_downloads_hint">Καθυστερεί την αναστολή παρασκηνίου όσο το Steam κάνει ενεργά λήψη.</string>
<string name="mode_zram">Συμπίεση zRAM</string>
<string name="mode_zram_hint">Όσο τρέχει ένα παιχνίδι, η μνήμη του Steam συμπιέζεται στο zram της συσκευής, ώστε το παιχνίδι να έχει περισσότερη RAM. Ισχύει την επόμενη φορά που θα ξεκινήσει το Steam.</string>
<string name="mode_zram_unsupported">Μη διαθέσιμο: η συσκευή δεν έχει zram ή swap, ή ο πυρήνας είναι παλαιότερος από 5.4.</string>
<string name="mode_suspend_note">Το Αυτόματο θέτει σε παύση στο παρασκήνιο και συνεχίζει όταν εμφανίζεται. Το Μη αυτόματο περιμένει το Συνέχιση. Το Ποτέ συνεχίζει να εκτελείται.</string>
<string name="mode_startup">Εκκίνηση</string>
<string name="mode_steam_startup">Εκκίνηση του Steam με το DroidDeck</string>
-3
View File
@@ -2571,9 +2571,6 @@
<string name="storage_tool_x64_client">Lo usa el cliente de Steam x86-64</string>
<string name="storage_uninstall_in_x64">Esta copia está en el cliente de Steam x86-64. Elige x86-64 en Cliente de Steam y desinstálala aquí.</string>
<string name="storage_uninstall_in_arm64">Esta copia está en el cliente de Steam ARM64. Elige ARM64 en Cliente de Steam y desinstálala aquí.</string>
<string name="mode_zram">Compresión zRAM</string>
<string name="mode_zram_hint">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.</string>
<string name="mode_zram_unsupported">No disponible: este dispositivo no tiene swap activo, o su kernel es anterior a la 5.4.</string>
<string name="stores_storage_insufficient">No hay suficiente espacio libre: se necesitan %1$s y solo hay %2$s libres</string>
<string name="stores_storage_low">Espacio insuficiente (%1$s libres, se necesitan %2$s)</string>
<string name="stores_target_free_unknown">Espacio libre desconocido</string>
-3
View File
@@ -287,9 +287,6 @@
<string name="mode_suspend_steam_note">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ä.</string>
<string name="mode_background_downloads">Pidä lataukset käynnissä</string>
<string name="mode_background_downloads_hint">Viivästytä taustakeskeytystä, kun Steam lataa aktiivisesti.</string>
<string name="mode_zram">zRAM-pakkaus</string>
<string name="mode_zram_hint">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.</string>
<string name="mode_zram_unsupported">Ei käytettävissä: laitteessa ei ole zramia tai swappia, tai ydin on vanhempi kuin 5.4.</string>
<string name="mode_suspend_note">Automaattinen keskeyttää taustalla ja jatkaa, kun sovellus tulee näkyviin. Manuaalinen odottaa Jatka-painiketta. Ei koskaan pitää sovelluksen aina käynnissä.</string>
<string name="mode_startup">Käynnistys</string>
<string name="mode_steam_startup">Käynnistä Steam DroidDeckin kanssa</string>
-3
View File
@@ -2571,9 +2571,6 @@
<string name="drawer_component_downloads">Télécharger des composants</string>
<string name="drawer_component_downloads_hint">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.</string>
<string name="drawer_component_choose_download">Choisissez une version à télécharger</string>
<string name="mode_zram">Compression zRAM</string>
<string name="mode_zram_hint">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.</string>
<string name="mode_zram_unsupported">Indisponible : cet appareil n\'a pas de swap actif, ou son noyau est antérieur à la version 5.4.</string>
<string name="stores_storage_insufficient">Espace libre insuffisant : %1$s nécessaires, seulement %2$s libres</string>
<string name="stores_storage_low">Espace insuffisant (%1$s libres, %2$s nécessaires)</string>
<string name="stores_target_free_unknown">Espace libre inconnu</string>
-3
View File
@@ -287,9 +287,6 @@
<string name="mode_suspend_steam_note">ऑटो दिखने पर रुकता है और फिर से शुरू होता है। नेटिव पहले Steam को स्लीप के लिए तैयार करता है। मैनुअल फिर से शुरू करें का इंतज़ार करता है। कभी नहीं वाला चलता रहता है।</string>
<string name="mode_background_downloads">डाउनलोड चलते रहने दें</string>
<string name="mode_background_downloads_hint">Steam के सक्रिय रूप से डाउनलोड करते समय बैकग्राउंड सस्पेंशन को टालें।</string>
<string name="mode_zram">zRAM कम्प्रेशन</string>
<string name="mode_zram_hint">गेम चलने के दौरान Steam क्लाइंट की मेमोरी डिवाइस के zram में कम्प्रेस होती है, जिससे गेम को ज़्यादा RAM मिलती है। अगली बार Steam शुरू होने पर लागू होगा।</string>
<string name="mode_zram_unsupported">उपलब्ध नहीं: इस डिवाइस में zram या swap नहीं है, या कर्नेल 5.4 से पुराना है।</string>
<string name="mode_suspend_note">ऑटो बैकग्राउंड में रुकता है और दिखने पर फिर से शुरू होता है। मैनुअल फिर से शुरू करें का इंतज़ार करता है। कभी नहीं वाला चलता रहता है।</string>
<string name="mode_startup">स्टार्टअप</string>
<string name="mode_steam_startup">DroidDeck के साथ Steam शुरू करें</string>
-3
View File
@@ -287,9 +287,6 @@
<string name="mode_suspend_steam_note">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.</string>
<string name="mode_background_downloads">Letöltések futtatásának fenntartása</string>
<string name="mode_background_downloads_hint">Elhalasztja a háttérbeli felfüggesztést, amíg a Steam aktívan letölt.</string>
<string name="mode_zram">zRAM-tömörítés</string>
<string name="mode_zram_hint">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.</string>
<string name="mode_zram_unsupported">Nem elérhető: a készüléken nincs zram vagy swap, vagy a kernel régebbi az 5.4-nél.</string>
<string name="mode_suspend_note">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.</string>
<string name="mode_startup">Indítás</string>
<string name="mode_steam_startup">Steam indítása a DroidDeckkel</string>
-3
View File
@@ -287,9 +287,6 @@
<string name="mode_suspend_steam_note">Otomatis menjeda dan melanjutkan saat ditampilkan. Native menyiapkan Steam untuk tidur terlebih dahulu. Manual menunggu Lanjutkan. Tidak pernah terus berjalan.</string>
<string name="mode_background_downloads">Pertahankan unduhan tetap berjalan</string>
<string name="mode_background_downloads_hint">Menunda penjedaan latar belakang saat Steam sedang aktif mengunduh.</string>
<string name="mode_zram">Kompresi zRAM</string>
<string name="mode_zram_hint">Saat bermain, memori klien Steam dipadatkan ke zram perangkat sehingga gim mendapat lebih banyak RAM. Berlaku saat Steam dijalankan berikutnya.</string>
<string name="mode_zram_unsupported">Tidak tersedia: perangkat ini tidak punya zram atau swap, atau kernelnya lebih lama dari 5.4.</string>
<string name="mode_suspend_note">Otomatis menjeda di latar belakang dan melanjutkan saat ditampilkan. Manual menunggu Lanjutkan. Tidak pernah terus berjalan.</string>
<string name="mode_startup">Mulai</string>
<string name="mode_steam_startup">Mulai Steam bersama DroidDeck</string>
-3
View File
@@ -2561,9 +2561,6 @@
<string name="stores_hltb_completionist">Completista</string>
<string name="stores_hltb_hours">%1$s h</string>
<string name="stores_hltb_minutes">%1$d min</string>
<string name="mode_zram">Compressione zRAM</string>
<string name="mode_zram_hint">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.</string>
<string name="mode_zram_unsupported">Non disponibile: questo dispositivo non ha zram o swap, oppure il suo kernel è precedente alla 5.4.</string>
<string name="setup_reset_settings">Ripristina tutte le impostazioni</string>
<string name="setup_reset_settings_title">Ripristinare tutte le impostazioni?</string>
<string name="setup_reset_settings_body">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.</string>
-3
View File
@@ -287,9 +287,6 @@
<string name="mode_suspend_steam_note">自動では一時停止し、表示すると再開します。ネイティブでは先にSteamをスリープに備えさせます。手動では「再開」をタップするまで待機します。「しない」では実行し続けます。</string>
<string name="mode_background_downloads">ダウンロードを継続</string>
<string name="mode_background_downloads_hint">Steamがダウンロード中は、バックグラウンドでの一時停止を遅らせます。</string>
<string name="mode_zram">zRAM 圧縮</string>
<string name="mode_zram_hint">ゲームの実行中、Steam クライアントのメモリがデバイスの zram に圧縮され、ゲーム用のメモリが増えます。次回 Steam を起動したときに反映されます。</string>
<string name="mode_zram_unsupported">使用できません:このデバイスに zram もしくは swap がないか、カーネルが 5.4 未満です。</string>
<string name="mode_suspend_note">自動ではバックグラウンドで一時停止し、表示すると再開します。手動では「再開」をタップするまで待機します。「しない」では実行し続けます。</string>
<string name="mode_startup">起動</string>
<string name="mode_steam_startup">DroidDeck起動時にSteamを起動</string>
-3
View File
@@ -287,9 +287,6 @@
<string name="mode_suspend_steam_note">자동: 일시 중지되고 다시 표시되면 재개됩니다. 네이티브: 먼저 Steam을 절전에 대비시킵니다. 수동: \"재개\"를 탭할 때까지 기다립니다. 안 함: 계속 실행됩니다.</string>
<string name="mode_background_downloads">다운로드 계속 진행</string>
<string name="mode_background_downloads_hint">Steam이 다운로드 중일 때는 백그라운드 일시 중지를 미룹니다.</string>
<string name="mode_zram">zRAM 압축</string>
<string name="mode_zram_hint">게임을 실행하는 동안 Steam 클라이언트의 메모리가 기기의 zram으로 압축되어 게임에 더 많은 메모리가 남습니다. 다음에 Steam을 시작할 때 적용됩니다.</string>
<string name="mode_zram_unsupported">사용할 수 없음: 이 기기에 zram 또는 swap이 없거나 커널이 5.4 미만입니다.</string>
<string name="mode_suspend_note">자동: 백그라운드에서 일시 중지되고 다시 표시되면 재개됩니다. 수동: \"재개\"를 탭할 때까지 기다립니다. 안 함: 계속 실행됩니다.</string>
<string name="mode_startup">시작</string>
<string name="mode_steam_startup">DroidDeck을 열 때 Steam 시작</string>
-3
View File
@@ -287,9 +287,6 @@
<string name="mode_suspend_steam_note">Auto menjeda dan menyambung semula apabila dipaparkan. Asli menyediakan Steam untuk tidur dahulu. Manual menunggu Sambung. Jangan sekali-kali terus berjalan.</string>
<string name="mode_background_downloads">Teruskan muat turun</string>
<string name="mode_background_downloads_hint">Lambatkan penjedaan latar belakang semasa Steam aktif memuat turun.</string>
<string name="mode_zram">Pemampatan zRAM</string>
<string name="mode_zram_hint">Semasa bermain, memori klien Steam dimampatkan ke zram peranti supaya permainan mendapat lebih RAM. Berkuat kuasa pada permulaan Steam seterusnya.</string>
<string name="mode_zram_unsupported">Tidak tersedia: peranti ini tiada zram atau swap, atau kernelnya lebih lama daripada 5.4.</string>
<string name="mode_suspend_note">Auto menjeda di latar belakang dan menyambung semula apabila dipaparkan. Manual menunggu Sambung. Jangan sekali-kali terus berjalan.</string>
<string name="mode_startup">Permulaan</string>
<string name="mode_steam_startup">Mulakan Steam bersama DroidDeck</string>
-3
View File
@@ -287,9 +287,6 @@
<string name="mode_suspend_steam_note">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.</string>
<string name="mode_background_downloads">La nedlastinger fortsette</string>
<string name="mode_background_downloads_hint">Utsetter bakgrunnspause mens Steam laster aktivt ned.</string>
<string name="mode_zram">zRAM-komprimering</string>
<string name="mode_zram_hint">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.</string>
<string name="mode_zram_unsupported">Ikke tilgjengelig: enheten har ingen zram eller swap, eller kjernen er eldre enn 5.4.</string>
<string name="mode_suspend_note">Auto setter på pause i bakgrunnen og gjenopptar når den vises. Manuell venter på Gjenoppta. Aldri lar den fortsette å kjøre.</string>
<string name="mode_startup">Oppstart</string>
<string name="mode_steam_startup">Start Steam med DroidDeck</string>
-3
View File
@@ -287,9 +287,6 @@
<string name="mode_suspend_steam_note">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.</string>
<string name="mode_background_downloads">Downloads laten doorlopen</string>
<string name="mode_background_downloads_hint">Stel onderbreken op de achtergrond uit zolang Steam actief downloadt.</string>
<string name="mode_zram">zRAM-compressie</string>
<string name="mode_zram_hint">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.</string>
<string name="mode_zram_unsupported">Niet beschikbaar: dit apparaat heeft geen zram of swap, of de kernel is ouder dan 5.4.</string>
<string name="mode_suspend_note">Automatisch pauzeert op de achtergrond en hervat zodra de app weer zichtbaar is. Handmatig wacht op Hervatten. Nooit laat alles doorlopen.</string>
<string name="mode_startup">Opstarten</string>
<string name="mode_steam_startup">Steam starten met DroidDeck</string>
-3
View File
@@ -287,9 +287,6 @@
<string name="mode_suspend_steam_note">„Auto” wstrzymuje i wznawia po powrocie. „Natywnie” najpierw przygotowuje Steam do uśpienia. „Ręcznie” czeka na „Wznów”. „Nigdy” nie przerywa działania.</string>
<string name="mode_background_downloads">Nie przerywaj pobierania</string>
<string name="mode_background_downloads_hint">Opóźnia wstrzymanie w tle, gdy Steam aktywnie pobiera.</string>
<string name="mode_zram">Kompresja zRAM</string>
<string name="mode_zram_hint">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.</string>
<string name="mode_zram_unsupported">Niedostępne: to urządzenie nie ma zram ani swapu, albo jądro jest starsze niż 5.4.</string>
<string name="mode_suspend_note">„Auto” wstrzymuje w tle i wznawia po powrocie. „Ręcznie” czeka na „Wznów”. „Nigdy” nie przerywa działania.</string>
<string name="mode_startup">Uruchamianie</string>
<string name="mode_steam_startup">Uruchamiaj Steam z DroidDeck</string>
@@ -2569,9 +2569,6 @@
<string name="setup_reset_settings_button">Repor</string>
<string name="setup_reset_settings_profiles">Repor também os perfis de jogo (Proton, FEX, componentes e ambiente de cada jogo e o ambiente do perfil Padrão)</string>
<string name="setup_reset_settings_session_running">Termine a sessão antes de repor as definições.</string>
<string name="mode_zram">Compressão zRAM</string>
<string name="mode_zram_unsupported">Indisponível: este dispositivo não tem swap ativo ou o kernel é anterior à versão 5.4.</string>
<string name="mode_zram_hint">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.</string>
<string name="stores_storage_low">Espaço insuficiente (%1$s livres, são necessários %2$s)</string>
<string name="stores_storage_insufficient">Espaço livre insuficiente: são necessários %1$s, só há %2$s livres</string>
<string name="stores_target_free_unknown">Espaço livre desconhecido</string>
-3
View File
@@ -2561,9 +2561,6 @@
<string name="stores_hltb_completionist">Completista</string>
<string name="stores_hltb_hours">%1$s h</string>
<string name="stores_hltb_minutes">%1$d min</string>
<string name="mode_zram">Compressão zRAM</string>
<string name="mode_zram_hint">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.</string>
<string name="mode_zram_unsupported">Indisponível: este dispositivo não tem swap ativo, ou o kernel é anterior à versão 5.4.</string>
<string name="setup_reset_settings">Redefinir todas as configurações</string>
<string name="setup_reset_settings_title">Redefinir todas as configurações?</string>
<string name="setup_reset_settings_body">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.</string>
-3
View File
@@ -287,9 +287,6 @@
<string name="mode_suspend_steam_note">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.</string>
<string name="mode_background_downloads">Continuă descărcările</string>
<string name="mode_background_downloads_hint">Întârzie suspendarea în fundal cât timp Steam descarcă activ.</string>
<string name="mode_zram">Compresie zRAM</string>
<string name="mode_zram_hint">Î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.</string>
<string name="mode_zram_unsupported">Indisponibil: dispozitivul nu are zram sau swap, ori kernelul e mai vechi decât 5.4.</string>
<string name="mode_suspend_note">Automat pune pe pauză în fundal și reia atunci când este afișată. Manual așteaptă Reluare. Niciodată continuă să ruleze.</string>
<string name="mode_startup">Pornire</string>
<string name="mode_steam_startup">Pornește Steam odată cu DroidDeck</string>
-3
View File
@@ -2596,9 +2596,6 @@
<string name="drawer_component_downloads">Скачать компоненты</string>
<string name="drawer_component_downloads_hint">Загрузки общие со страницей «Компоненты». Выберите выше скачанную версию, чтобы использовать её при следующем запуске игры.</string>
<string name="drawer_component_choose_download">Выберите версию для скачивания</string>
<string name="mode_zram">Сжатие zRAM</string>
<string name="mode_zram_hint">Пока идёт игра, память клиента Steam сжимается в zram устройства, и игре остаётся больше ОЗУ. Применяется при следующем запуске Steam.</string>
<string name="mode_zram_unsupported">Недоступно: на устройстве нет zram или swap, либо его ядро старше 5.4.</string>
<string name="stores_storage_insufficient">Недостаточно свободного места: нужно %1$s, свободно только %2$s</string>
<string name="stores_storage_low">Недостаточно места (свободно %1$s, нужно %2$s)</string>
<string name="stores_target_free_unknown">Свободное место неизвестно</string>
-3
View File
@@ -287,9 +287,6 @@
<string name="mode_suspend_steam_note">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.</string>
<string name="mode_background_downloads">Håll nedladdningar igång</string>
<string name="mode_background_downloads_hint">Skjut upp bakgrundspausning medan Steam aktivt laddar ned.</string>
<string name="mode_zram">zRAM-komprimering</string>
<string name="mode_zram_hint">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.</string>
<string name="mode_zram_unsupported">Inte tillgängligt: enheten har ingen zram eller swap, eller så är kärnan äldre än 5.4.</string>
<string name="mode_suspend_note">Auto pausar i bakgrunden och återupptar när den visas. Manuell väntar på Återuppta. Aldrig fortsätter köra.</string>
<string name="mode_startup">Uppstart</string>
<string name="mode_steam_startup">Starta Steam med DroidDeck</string>
-3
View File
@@ -287,9 +287,6 @@
<string name="mode_suspend_steam_note">อัตโนมัติจะหยุดชั่วคราวและดำเนินการต่อเมื่อแสดงผล ดั้งเดิมเตรียม Steam เข้าสู่โหมดพักก่อน กำหนดเองรอการกดดำเนินการต่อ ไม่มีวันทำงานต่อเนื่องตลอด</string>
<string name="mode_background_downloads">ให้การดาวน์โหลดทำงานต่อไป</string>
<string name="mode_background_downloads_hint">เลื่อนการหยุดพื้นหลังชั่วคราวออกไปขณะ Steam กำลังดาวน์โหลดอยู่</string>
<string name="mode_zram">การบีบอัด zRAM</string>
<string name="mode_zram_hint">ระหว่างเล่นเกม หน่วยความจำของไคลเอนต์ Steam จะถูกบีบอัดลง zram ของอุปกรณ์ ทำให้เกมได้ RAM มากขึ้น มีผลเมื่อเริ่ม Steam ครั้งถัดไป</string>
<string name="mode_zram_unsupported">ใช้ไม่ได้: อุปกรณ์นี้ไม่มี zram หรือ swap หรือเคอร์เนลเก่ากว่า 5.4</string>
<string name="mode_suspend_note">อัตโนมัติจะหยุดชั่วคราวเมื่ออยู่ในพื้นหลังและดำเนินการต่อเมื่อแสดงผล กำหนดเองรอการกดดำเนินการต่อ ไม่มีวันทำงานต่อเนื่องตลอด</string>
<string name="mode_startup">การเริ่มทำงาน</string>
<string name="mode_steam_startup">เริ่ม Steam พร้อมกับ DroidDeck</string>
-3
View File
@@ -2538,9 +2538,6 @@
<string name="stores_hltb_completionist">Tamamlayıcı</string>
<string name="stores_hltb_hours">%1$s sa</string>
<string name="stores_hltb_minutes">%1$d dk</string>
<string name="mode_zram">zRAM sıkıştırma</string>
<string name="mode_zram_hint">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.</string>
<string name="mode_zram_unsupported">Kullanılamıyor: bu cihazda zram veya swap yok ya da çekirdeği 5.4\'ten eski.</string>
<string name="setup_reset_settings">Tüm ayarları sıfırla</string>
<string name="setup_reset_settings_title">Tüm ayarlar sıfırlansın mı?</string>
<string name="setup_reset_settings_body">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.</string>
-3
View File
@@ -287,9 +287,6 @@
<string name="mode_suspend_steam_note">«Авто» призупиняє сеанс і відновлює його, коли додаток знову на екрані. «Нативний» спершу готує Steam до сну. «Вручну» чекає на «Відновити». «Ніколи» — сеанс працює далі.</string>
<string name="mode_background_downloads">Не переривати завантаження</string>
<string name="mode_background_downloads_hint">Відкладає призупинення у фоні, поки Steam активно щось завантажує.</string>
<string name="mode_zram">Стиснення zRAM</string>
<string name="mode_zram_hint">Під час гри пам\'ять клієнта Steam стискається в zram пристрою, тож гра отримує більше RAM. Застосується під час наступного запуску Steam.</string>
<string name="mode_zram_unsupported">Недоступно: на пристрої немає zram або swap, чи ядро старіше за 5.4.</string>
<string name="mode_suspend_note">«Авто» призупиняє сеанс у фоні й відновлює, коли додаток знову на екрані. «Вручну» чекає на «Відновити». «Ніколи» — сеанс працює далі.</string>
<string name="mode_startup">Запуск</string>
<string name="mode_steam_startup">Запускати Steam разом із DroidDeck</string>
-3
View File
@@ -287,9 +287,6 @@
<string name="mode_suspend_steam_note">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.</string>
<string name="mode_background_downloads">Giữ tải xuống tiếp tục chạy</string>
<string name="mode_background_downloads_hint">Trì hoãn tạm dừng nền khi Steam đang tải xuống.</string>
<string name="mode_zram">Nén zRAM</string>
<string name="mode_zram_hint">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.</string>
<string name="mode_zram_unsupported">Không khả dụng: thiết bị không có zram hoặc swap, hoặc nhân cũ hơn 5.4.</string>
<string name="mode_suspend_note">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.</string>
<string name="mode_startup">Khởi động</string>
<string name="mode_steam_startup">Khởi động Steam cùng DroidDeck</string>
@@ -287,9 +287,6 @@
<string name="mode_suspend_steam_note">自动会暂停并在显示时恢复。原生会先让 Steam 做好睡眠准备。手动会等待点按恢复。“从不”则保持运行。</string>
<string name="mode_background_downloads">后台继续下载</string>
<string name="mode_background_downloads_hint">Steam 正在下载时推迟后台暂停。</string>
<string name="mode_zram">zRAM 压缩</string>
<string name="mode_zram_hint">游戏运行时,Steam 客户端的内存会压缩进设备的 zram,为游戏留出更多内存。下次启动 Steam 时生效。</string>
<string name="mode_zram_unsupported">不可用:此设备没有 zram 或 swap,或其内核低于 5.4。</string>
<string name="mode_suspend_note">自动会在后台暂停并在显示时恢复。手动会等待点按恢复。“从不”则保持运行。</string>
<string name="mode_startup">启动</string>
<string name="mode_steam_startup">随 DroidDeck 启动 Steam</string>
@@ -287,9 +287,6 @@
<string name="mode_suspend_steam_note">「自動」會暫停,並在重新顯示時繼續;「原生」會先讓 Steam 準備好休眠;「手動」會等你按「繼續」;「從不」會保持執行。</string>
<string name="mode_background_downloads">保持下載進行</string>
<string name="mode_background_downloads_hint">Steam 正在下載時,延遲在背景暫停。</string>
<string name="mode_zram">zRAM 壓縮</string>
<string name="mode_zram_hint">遊戲運行時,Steam 用戶端的記憶體會壓縮進裝置的 zram,為遊戲騰出更多記憶體。下次啟動 Steam 時生效。</string>
<string name="mode_zram_unsupported">無法使用:此裝置沒有 zram 或 swap,或核心版本低於 5.4。</string>
<string name="mode_suspend_note">「自動」會在背景時暫停,並在重新顯示時繼續;「手動」會等你按「繼續」;「從不」會保持執行。</string>
<string name="mode_startup">啟動</string>
<string name="mode_steam_startup">隨 DroidDeck 啟動 Steam</string>
@@ -287,9 +287,6 @@
<string name="mode_suspend_steam_note">「自動」會暫停,並在重新顯示時繼續。「原生」會先讓 Steam 做好睡眠準備。「手動」會等你點擊「繼續」。「從不」則會持續執行。</string>
<string name="mode_background_downloads">讓下載持續進行</string>
<string name="mode_background_downloads_hint">Steam 正在下載時,延後在背景暫停。</string>
<string name="mode_zram">zRAM 壓縮</string>
<string name="mode_zram_hint">遊戲執行時,Steam 用戶端記憶體會壓縮進裝置的 zram,為遊戲騰出更多記憶體。下次啟動 Steam 時生效。</string>
<string name="mode_zram_unsupported">無法使用:此裝置沒有 zram 或 swap,或核心版本低於 5.4。</string>
<string name="mode_suspend_note">「自動」會在背景時暫停,並在重新顯示時繼續。「手動」會等你點擊「繼續」。「從不」則會持續執行。</string>
<string name="mode_startup">啟動</string>
<string name="mode_steam_startup">隨 DroidDeck 啟動 Steam</string>
-3
View File
@@ -292,9 +292,6 @@
<string name="mode_suspend_steam_note">Auto pauses and resumes when shown. Native prepares Steam for sleep first. Manual waits for Resume. Never keeps running.</string>
<string name="mode_background_downloads">Keep downloads running</string>
<string name="mode_background_downloads_hint">Delay background suspension while Steam is actively downloading.</string>
<string name="mode_zram">zRAM compression</string>
<string name="mode_zram_hint">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.</string>
<string name="mode_zram_unsupported">Unavailable: this device has no zram or swap, or its kernel is older than 5.4.</string>
<string name="mode_suspend_note">Auto pauses in the background and resumes when shown. Manual waits for Resume. Never keeps running.</string>
<string name="mode_startup">Startup</string>
<string name="mode_steam_startup">Start Steam with DroidDeck</string>
@@ -21,6 +21,7 @@ pub struct Game {
pub parent_product_id: String,
pub art: String,
pub hero: String,
pub logo: String,
pub developer: String,
pub publisher: String,
}
@@ -39,6 +40,7 @@ impl Game {
"sku": self.sku,
"art": self.art,
"hero": self.hero,
"logo": self.logo,
"developer": self.developer,
"publisher": self.publisher,
"dlc": self.is_dlc(),
@@ -83,6 +85,7 @@ pub fn read_entitlement(e: &Value) -> Option<Game> {
],
)
.to_string(),
logo: str_at(product, &["productDetail", "details", "logoUrl"]).to_string(),
developer: str_at(product, &["productDetail", "details", "developer"]).to_string(),
publisher: str_at(product, &["productDetail", "details", "publisher"]).to_string(),
})
@@ -178,17 +181,20 @@ mod tests {
fn entitlement_fields_and_fallbacks() {
let e = json!({"id":"ent-1","product":{"id":"amzn1.adg.product.x","title":"Game","sku":"SKU1",
"productDetail":{"iconUrl":"","details":{"pgCrownImageUrl":"crown","backgroundUrl1":"bg1",
"developer":"Dev","publisher":"Pub","productType":"GAME"}}}});
"logoUrl":"logo","developer":"Dev","publisher":"Pub","productType":"GAME"}}}});
let g = read_entitlement(&e).unwrap();
assert_eq!(g.entitlement_id, "ent-1");
assert_eq!(g.art, "crown");
assert_eq!(g.hero, "bg1");
assert_eq!(g.logo, "logo");
assert_eq!(g.to_json()["logo"], "logo");
assert_eq!(g.product_type, "GAME");
assert!(!g.is_dlc());
let untitled = read_entitlement(&json!({"product":{"id":"p","type":"DLC","baseProductId":"q"}})).unwrap();
assert_eq!(untitled.title, "Unknown");
assert_eq!(untitled.entitlement_id, "");
assert_eq!(untitled.parent_product_id, "q");
assert_eq!(untitled.logo, "");
assert!(untitled.is_dlc());
assert!(read_entitlement(&json!({"id":"x"})).is_none());
assert!(read_entitlement(&json!({"id":"x","product":{"id":""}})).is_none());
+5 -1
View File
@@ -71,6 +71,7 @@ pub fn details_of(entry: &Value) -> Value {
"releaseDate": release,
"tall": image(entry, &["DieselGameBoxTall"]),
"wide": image(entry, &["DieselGameBox", "Thumbnail"]),
"logo": image(entry, &["DieselGameBoxLogo"]),
"canRunOffline": offline,
"dlc": main_game.is_some_and(|m| !m.is_null()),
"mainGameId": main_game.map(|m| net::str_of(m, "id")).unwrap_or(""),
@@ -102,7 +103,8 @@ mod tests {
"keyImages": [
{"type": "Thumbnail", "url": "thumb"},
{"type": "DieselGameBox", "url": ""},
{"type": "DieselGameBoxTall", "url": "tall"}
{"type": "DieselGameBoxTall", "url": "tall"},
{"type": "DieselGameBoxLogo", "url": "logo"}
],
"customAttributes": {"CanRunOffline": {"value": "FALSE"}, "publisherName": {"value": " Pub "}},
"seller": {"name": "Seller"},
@@ -113,6 +115,7 @@ mod tests {
assert_eq!(d["title"], "Game");
assert_eq!(d["tall"], "tall");
assert_eq!(d["wide"], "thumb");
assert_eq!(d["logo"], "logo");
assert_eq!(d["publisher"], "Pub");
assert_eq!(d["canRunOffline"], false);
assert_eq!(d["releaseDate"], "2020-01-01T00:00:00.000Z");
@@ -123,6 +126,7 @@ mod tests {
assert_eq!(dlc["publisher"], "S");
assert_eq!(dlc["releaseDate"], "v");
assert_eq!(dlc["canRunOffline"], true);
assert_eq!(dlc["logo"], "");
assert_eq!(pick_entry(&json!({"x": {"title": "Only"}}), "y").unwrap()["title"], "Only");
}
}
@@ -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")))
@@ -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<Set<Slot>>()
val sgdb = ArrayList<Set<Slot>>()
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<Slot> = emptySet(), sgdbHas: Set<Slot> = 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())
}
}
@@ -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)
}
}
@@ -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<Runnable>()
private val executor = object : AbstractExecutorService() {
override fun execute(command: Runnable) { work.addLast(command) }
override fun shutdown() {}
override fun shutdownNow(): MutableList<Runnable> = 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<Void>(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<String>(service, "steamSleepToken"))
assertEquals(1, work.size)
assertFalse(ReflectionHelpers.getField(service, "suspendAttemptFailed"))
}
@Test fun cancelledFreezeCompletesItsOwnRequestWithoutRetryingIt() {
ReflectionHelpers.setField(service, "steamSleepToken", "old-request")
ReflectionHelpers.callInstanceMethod<Void>(service, "updateSuspendPolicy")
ReflectionHelpers.setField(service, "freezeCancelled", true)
work.removeFirst().run()
shadowOf(Looper.getMainLooper()).idle()
assertEquals(null, ReflectionHelpers.getField<String?>(service, "steamSleepToken"))
assertEquals(0, work.size)
assertFalse(ReflectionHelpers.getField(service, "suspendAttemptFailed"))
}
}
@@ -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<Task>)
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<Triple<Int, Long, Long>>()
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
}
}
@@ -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)
@@ -34,6 +34,17 @@ class StoreGameSidecarTest {
assertEquals("ns", back.extra["namespace"])
}
@Test fun theStoreLogoRoundTripsAndOlderFilesHaveNone() {
val withLogo = sample.copy(logo = "https://x/logo.png")
val back = StoreGameSidecar.parse(withLogo.toJson().toString())!!
assertEquals("https://x/logo.png", back.logo)
assertEquals("https://x/logo.png", back.copy(state = StoreGameSidecar.STATE_UPDATING).logo)
assertFalse(sample.toJson().has("logo"))
assertNull(StoreGameSidecar.parse(sample.toJson().toString())!!.logo)
assertNull(StoreGameSidecar.parse("""{"store":"gog","id":"1","title":"T","exe":"game.exe","cover":"c","hero":"h"}""")!!.logo)
assertNull(StoreGameSidecar.parse("""{"store":"gog","id":"1","title":"T","exe":"game.exe","logo":""}""")!!.logo)
}
@Test fun defaultsWhenFieldsAreAbsent() {
val back = StoreGameSidecar.parse("""{"store":"gog","id":"1","title":"T","exe":"bin\\game.exe","addToSteam":false}""")!!
assertEquals("bin/game.exe", back.exe)
@@ -0,0 +1,88 @@
package com.droiddeck.launcher.stores
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Color
import com.droiddeck.launcher.stores.amazon.AmazonLibrary
import com.droiddeck.launcher.stores.gog.GogBackend
import org.json.JSONObject
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.io.ByteArrayOutputStream
import java.io.File
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class StoreLogoTest {
@get:Rule val tmp = TemporaryFolder()
private fun encoded(format: Bitmap.CompressFormat): ByteArray {
val bmp = Bitmap.createBitmap(8, 4, Bitmap.Config.ARGB_8888)
bmp.eraseColor(Color.TRANSPARENT)
bmp.setPixel(1, 1, Color.RED)
return ByteArrayOutputStream().also { bmp.compress(format, 100, it) }.toByteArray()
}
@Test fun aPngLogoIsKeptAsItCameWithItsTransparency() {
val png = encoded(Bitmap.CompressFormat.PNG)
val dst = File(tmp.root, StoreArt.LOGO)
assertTrue(StoreArt.writeLogo(png, dst))
assertArrayEquals(png, dst.readBytes())
val back = BitmapFactory.decodeFile(dst.path)
assertEquals(0, Color.alpha(back.getPixel(0, 0)))
assertEquals(Color.RED, back.getPixel(1, 1))
assertFalse(File(dst.path + ".tmp").exists())
}
@Test fun anotherImageFormatIsStoredAsPng() {
val dst = File(tmp.root, StoreArt.LOGO)
assertTrue(StoreArt.writeLogo(encoded(Bitmap.CompressFormat.JPEG), dst))
val head = dst.readBytes().take(4)
assertEquals(listOf(0x89.toByte(), 'P'.code.toByte(), 'N'.code.toByte(), 'G'.code.toByte()), head)
assertEquals(8, BitmapFactory.decodeFile(dst.path).width)
}
@Test fun whatIsNotAnImageIsRefusedAndAnExistingLogoStays() {
val dst = File(tmp.root, StoreArt.LOGO)
assertFalse(StoreArt.writeLogo("<html>not found</html>".toByteArray(), dst))
assertFalse(dst.exists())
dst.writeBytes(byteArrayOf(1, 2, 3))
assertTrue(StoreArt.writeLogo(encoded(Bitmap.CompressFormat.PNG), dst))
assertArrayEquals(byteArrayOf(1, 2, 3), dst.readBytes())
assertTrue(StoreArt.fetchLogo(tmp.root, "https://invalid.example/logo.png"))
}
@Test fun gogTakesTheV2LogoLinkOnly() {
val v2 = JSONObject("""{"_links":{"logo":{"href":"https://images.gog-statics.com/abc_logo.png"},"galaxyBackgroundImage":{"href":"https://x/bg.jpg"}}}""")
assertEquals("https://images.gog-statics.com/abc_logo.png", GogBackend.logoOf(v2))
assertEquals("https://images.gog-statics.com/rel.png", GogBackend.logoOf(JSONObject("""{"_links":{"logo":{"href":"//images.gog-statics.com/rel.png"}}}""")))
assertNull(GogBackend.logoOf(JSONObject("""{"_links":{}}""")))
assertNull(GogBackend.logoOf(JSONObject("""{"images":{"logo2x":"https://x/glx_logo_2x.jpg"}}""")))
assertNull(GogBackend.logoOf(JSONObject("""{"_links":{"logo":{"href":"http://x/logo.png"}}}""")))
}
@Test fun amazonKeepsTheLogoFromTheLibraryAndItsCache() {
val app = RuntimeEnvironment.getApplication()
val fresh = AmazonLibrary.gameFrom(JSONObject().put("productId", "p").put("title", "Game").put("logo", "https://m/logo.png"), null)
assertEquals("https://m/logo.png", fresh.logoUrl)
assertEquals("https://m/logo.png", AmazonLibrary.toCatalogItem(app, fresh).logoUrl)
assertEquals(listOf(fresh), AmazonLibrary.fromCacheJson(AmazonLibrary.toCacheJson(listOf(fresh))))
val kept = AmazonLibrary.gameFrom(JSONObject().put("productId", "p").put("title", "Game"), fresh)
assertEquals("https://m/logo.png", kept.logoUrl)
val none = AmazonLibrary.gameFrom(JSONObject().put("productId", "q").put("title", "Other"), null)
assertNull(AmazonLibrary.toCatalogItem(app, none).logoUrl)
assertEquals("", AmazonLibrary.fromCacheJson("""[{"productId":"old","title":"Old"}]""").single().logoUrl)
}
}
@@ -83,6 +83,18 @@ class EpicLibraryTest {
assertEquals("Fig", EpicLibrary.gameFrom(record, null, null).title)
}
@Test fun theCatalogLogoIsKeptInTheCacheAndHandedToTheInstall() {
val record = JSONObject().put("appName", "Fig").put("namespace", "ns").put("catalogItemId", "cat")
val fresh = EpicLibrary.gameFrom(record, JSONObject().put("logo", "https://e/logo.png"), null)
assertEquals("https://e/logo.png", fresh.artLogo)
assertEquals("https://e/logo.png", EpicLibrary.gameFrom(record, JSONObject().put("title", "Fig"), fresh).artLogo)
assertEquals(listOf(fresh), EpicLibrary.fromCacheJson(EpicLibrary.toCacheJson(listOf(fresh))))
assertEquals("https://e/logo.png", EpicLibrary.toCatalogItem(app, fresh).logoUrl)
val plain = EpicGame("Old", "ns", "c", "Old")
assertFalse(JSONArray(EpicLibrary.toCacheJson(listOf(plain))).getJSONObject(0).has("artLogo"))
assertEquals(null, EpicLibrary.toCatalogItem(app, plain).logoUrl)
}
@Test fun signOutForgetsOnlyTheLibrary() {
EpicPrefs.get(app).edit().putString(EpicLibrary.KEY_CACHE, "[]").putLong(EpicLibrary.KEY_SYNCED_AT, 1L)
.putString("cloud_template_Fig", "{AppData}/x").commit()
@@ -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))
}
}
@@ -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
+21 -2
View File
@@ -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 <ZR_CTL>.ack.<pid> 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");
+1 -1
View File
@@ -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
+6 -2
View File
@@ -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):