Files
DroidDeck/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt
T
The412Banner f2ecda239a Use the adaptive Epic engine by default, keep bounded and batch modes
EpicDownloadManager runs EpicNative.stream when the library loads and its
per-volume space plan fits (cache on internal scratch for card installs,
beside the game when internal storage is full), and falls back to the
bounded Java writer otherwise. A hidden storesEpicMode preference selects
adaptive, batch (whole-cache fetch then native assembly, with its 2x
space check) or java for A/B runs; each mode logs a summary line.
2026-10-13 20:57:43 -04:00

1098 lines
54 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.droiddeck.launcher.session
import android.content.Context
import android.content.pm.ActivityInfo
import androidx.annotation.StringRes
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
/** The in-session switches: the HUD and how the on-screen controls decide to appear. */
object SessionPrefs {
data class GameTextureFiltering(val anisotropy: Int? = null, val lodBias: String? = null)
const val SUSPEND_AUTO = "auto"
const val SUSPEND_NATIVE = "native"
const val SUSPEND_MANUAL = "manual"
const val SUSPEND_NEVER = "never"
private const val LEGACY_SUSPEND_DOWNLOADS = "downloads"
private const val STEAM_DOWNLOADS_IN_BACKGROUND = "steamDownloadsInBackground"
const val CONTROLLER_DECK = "deck"
const val CONTROLLER_XBOX360 = "xbox360"
const val CONTROLLER_STEAM = "steamcontroller"
const val CONTROLLER_DS4 = "dualshock4"
const val CONTROLLER_DUALSENSE = "dualsense"
const val OSC_AUTO = "auto"
const val OSC_ALWAYS = "always"
const val OSC_STEAM_QAM = "steam-qam"
const val OSC_NEVER = "never"
/** Steam's touch controller (SteamTouchControls) in place of the app's pad; Steam sessions only. */
const val OSC_STEAM_TOUCH = "steam-touch"
/** What Back does in a Steam session, first press then second: the labels of the two orders. */
val BACK_MENU_THEN_QAM = R.string.back_menu_then_qam
val BACK_QAM_THEN_MENU = R.string.back_qam_then_menu
@StringRes
fun backActionsOrder(inverted: Boolean): Int =
if (inverted) BACK_QAM_THEN_MENU else BACK_MENU_THEN_QAM
private fun prefs(context: Context) = context.getSharedPreferences("session", Context.MODE_PRIVATE)
val orientationOptions = listOf(
ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE to R.string.setup_orientation_auto,
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE to R.string.setup_orientation_landscape,
ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE to R.string.setup_orientation_reverse,
)
fun orientation(context: Context): Int =
prefs(context).getInt("orientation", ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
.takeIf { value -> orientationOptions.any { it.first == value } }
?: ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
fun setOrientation(context: Context, orientation: Int) {
require(orientationOptions.any { it.first == orientation })
prefs(context).edit().putInt("orientation", orientation).apply()
}
/** Whether the launcher hides Android's status and navigation bars. */
fun launcherFullscreen(context: Context): Boolean = prefs(context).getBoolean("launcherFullscreen", true)
fun setLauncherFullscreen(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("launcherFullscreen", on).apply()
}
fun animationsEnabled(context: Context): Boolean = prefs(context).getBoolean("animations", true)
fun setAnimationsEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("animations", on).apply()
}
fun gameStoresEnabled(context: Context): Boolean = prefs(context).getBoolean("gameStoresEnabled", true)
fun setGameStoresEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("gameStoresEnabled", on).apply()
}
/** The store download engine's speed tier (stores/download/StoreDownloadTier ids); Fast unless chosen otherwise. */
fun gameStoresSpeedTier(context: Context): String = prefs(context).getString("gameStoresSpeedTier", "fast") ?: "fast"
fun setGameStoresSpeedTier(context: Context, tier: String) {
prefs(context).edit().putString("gameStoresSpeedTier", tier).apply()
}
/**
* Hidden A/B switch for the Epic installer: "adaptive" (default: native parallel fetch written
* straight into staged files), "batch" (the older whole-cache native fetch, then native
* assembly; needs about twice the game's size free) or "java" (the bounded one-chunk-at-a-time
* Java writer). No UI; set it in the `session` preferences for benchmarks.
*/
fun storesEpicMode(context: Context): String = prefs(context).getString("storesEpicMode", "adaptive") ?: "adaptive"
/** Which tab a signed-in store opens on when its chip is picked: [STORES_OPEN_LIBRARY] or [STORES_OPEN_STORE]. */
fun storesOpenTab(context: Context): String =
prefs(context).getString("storesOpenTab", STORES_OPEN_LIBRARY)?.takeIf { it == STORES_OPEN_STORE } ?: STORES_OPEN_LIBRARY
fun setStoresOpenTab(context: Context, tab: String) {
prefs(context).edit().putString("storesOpenTab", if (tab == STORES_OPEN_STORE) STORES_OPEN_STORE else STORES_OPEN_LIBRARY).apply()
}
/** Mature titles (the stores' own 17+/18 ratings or adult tags) in the storefront's shelves and search; off by default. Owned games always show. */
fun storesShowMature(context: Context): Boolean = prefs(context).getBoolean("storesShowMature", false)
fun setStoresShowMature(context: Context, show: Boolean) {
prefs(context).edit().putBoolean("storesShowMature", show).apply()
}
const val STORES_OPEN_LIBRARY = "library"
const val STORES_OPEN_STORE = "store"
/** The install root picked last time the Stores asked where (a StoreInstallRoot target's path); only the dialog's default, never a silent choice. */
fun storesInstallTarget(context: Context): String = prefs(context).getString("storesInstallTarget", "") ?: ""
fun setStoresInstallTarget(context: Context, path: String) {
prefs(context).edit().putString("storesInstallTarget", path).apply()
}
/** How many store downloads run at once, 1..3; one by default (a single download already fills the link). */
fun gameStoresParallel(context: Context): Int = prefs(context).getInt("gameStoresParallel", 1).coerceIn(1, 3)
fun setGameStoresParallel(context: Context, count: Int) {
prefs(context).edit().putInt("gameStoresParallel", count.coerceIn(1, 3)).apply()
}
/**
* The session's performance HUD (the fps box). A Deck-mode Steam session with the performance
* overlay has Steam's own (mangoapp, from the QAM), so there the HUD is off unless turned on
* in one - a choice kept apart from every other session's, where it stays on by default.
*/
fun hudEnabled(context: Context): Boolean = prefs(context).getBoolean(hudKey(context), !mangoappSession(context))
fun setHudEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean(hudKey(context), on).apply()
}
private fun hudKey(context: Context) = if (mangoappSession(context)) "hudDeck" else "hud"
/** A Steam session with Deck mode's performance overlay (mangoapp). */
private fun mangoappSession(context: Context) =
SessionState.mode == SessionService.MODE_STEAM && mangoapp(context)
/** When enabled, a single Back opens Steam QAM and a double Back opens the session menu. */
fun backActionsInverted(context: Context): Boolean = prefs(context).getBoolean("backActionsInverted", false)
fun setBackActionsInverted(context: Context, inverted: Boolean) {
prefs(context).edit().putBoolean("backActionsInverted", inverted).apply()
}
const val TOUCH_AUTO = "auto"
const val TOUCH_PAD = "touchpad"
const val TOUCH_DIRECT = "direct"
const val TOUCH_OFF = "off"
/** How touch drives the pointer: a touchpad (drag moves it from where it is) or direct
* (it jumps under the finger). Auto = touchpad on the desktop, direct in Steam.
* Off ignores touches on the guest picture; Android controls remain usable. */
fun touchMode(context: Context): String = prefs(context).getString("touch", TOUCH_AUTO) ?: TOUCH_AUTO
fun setTouchMode(context: Context, mode: String) {
prefs(context).edit().putString("touch", mode).apply()
}
const val SHAPE_AUTO = "auto"
const val SHAPE_WIDE = "16:9"
const val SHAPE_EXACT = "exact"
/**
* The shape of the display the session presents: the panel's own (never narrower than 16:9),
* exactly the panel's (a 4:3 or 3:2 handheld, drawn edge to edge), or a fixed 16:9. A foldable defaults to 16:9, which sits with modest bars on either of its
* panels; the panel's own shape would fit one and leave a strip on the other, and gamescope's
* display cannot change size once the session is up.
*/
private fun shapeMode(context: Context): String =
prefs(context).getString("shape", null)
?: if (context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle")) SHAPE_WIDE else SHAPE_AUTO
fun oscMode(context: Context): String = prefs(context).getString("osc", OSC_AUTO) ?: OSC_AUTO
fun setOscMode(context: Context, mode: String) {
prefs(context).edit().putString("osc", mode).apply()
}
/**
* The imported glibc Turnip every session draws with inside the runtime - Steam, its games and
* the desktop alike, as they run on the same GPU; "" = the driver built into the runtime.
* Resolved by LinuxVulkanDriver at session start. It was once chosen per mode: the Steam
* session's choice, the one nearly everyone set, carries over.
*/
fun linuxDriver(context: Context): String =
prefs(context).getString("linuxDriver", null)
?: prefs(context).getString("linuxDriver.steam", null)
?: prefs(context).getString("linuxDriver.desktop", "") ?: ""
fun setLinuxDriver(context: Context, id: String) {
prefs(context).edit().putString("linuxDriver", id).apply()
}
const val GPU_DRIVERS_AUTO = "auto"
const val GPU_DRIVERS_MANUAL = "manual"
/**
* Who picks the GPU drivers: [GPU_DRIVERS_AUTO] (the app, the matched pair recommended for
* this GPU, kept current - DriverPairs) or [GPU_DRIVERS_MANUAL] (the user). Auto for new
* installs; see settleGpuDriverMode.
*/
fun gpuDriverMode(context: Context): String =
prefs(context).getString("gpuDriverMode", GPU_DRIVERS_AUTO) ?: GPU_DRIVERS_AUTO
fun setGpuDriverMode(context: Context, mode: String) {
prefs(context).edit().putString("gpuDriverMode", mode).apply()
}
/**
* Auto arrived after people had picked drivers by hand: an install that chose either driver
* keeps its choice (Manual), everyone else is Auto. Decided once, at process start.
*/
fun settleGpuDriverMode(context: Context) {
val p = prefs(context)
if (p.contains("gpuDriverMode")) return
val chosen = androidDriver(context).isNotEmpty() || linuxDriver(context).isNotEmpty()
p.edit().putString("gpuDriverMode", if (chosen) GPU_DRIVERS_MANUAL else GPU_DRIVERS_AUTO).apply()
}
/** Drivers Auto downloaded: the only ones it removes when a newer pair replaces them. */
fun gpuAutoInstalled(context: Context): Set<String> =
prefs(context).getStringSet("gpuAutoInstalled", emptySet()).orEmpty()
fun setGpuAutoInstalled(context: Context, ids: Set<String>) {
prefs(context).edit().putStringSet("gpuAutoInstalled", ids.toSet()).apply()
}
// The Steam client's own sound has one route now: DirectAudio's engine inside the PulseAudio
// daemon (PulseAudioComponent), one step from Android. The old "clientDirectAudio" boolean
// (which meant the relay route, one hop more) is no longer read.
fun stretch16x9(context: Context): Boolean = prefs(context).getBoolean("stretch16x9", false)
fun setStretch16x9(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("stretch16x9", on).apply()
}
/**
* DirectAudio for games: their Wine audio driver talks to the relay helper on this side. On
* unless the user turned it off.
*/
fun directAudio(context: Context): Boolean = prefs(context).getBoolean("directAudio", true)
fun setDirectAudio(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("directAudio", on).apply()
}
/** The microphone for voice chat, off until the user turns it on (which asks for RECORD_AUDIO). */
fun micEnabled(context: Context): Boolean = prefs(context).getBoolean("mic", false)
/** Whether the app has already asked for the microphone once at start-up. */
fun micAsked(context: Context): Boolean = prefs(context).getBoolean("micAsked", false)
fun setMicAsked(context: Context) {
prefs(context).edit().putBoolean("micAsked", true).apply()
}
fun setMicEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("mic", on).apply()
}
/** Optional SSID reporting and scans. A Location grant alone never opts the user in. */
fun wifiDiscoveryEnabled(context: Context): Boolean = prefs(context).getBoolean("wifiDiscovery", false)
fun setWifiDiscoveryEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("wifiDiscovery", on).apply()
}
fun wifiDiscoveryAsked(context: Context): Boolean = prefs(context).getBoolean("wifiDiscoveryAsked", false)
fun setWifiDiscoveryAsked(context: Context) {
prefs(context).edit().putBoolean("wifiDiscoveryAsked", true).apply()
}
internal data class CoreAllocation(val clientOverride: Boolean, val clientCpus: String, val gameCpus: String)
/** Defaults are read only for absent keys; even a saved empty/all-core choice wins. */
internal fun coreAllocation(context: Context, defaults: CpuAllocationDefaults.Allocation? = CpuAllocationDefaults.device): CoreAllocation {
val saved = prefs(context)
return CoreAllocation(
saved.getBoolean("clientCpusOverride", defaults != null && !saved.contains("clientCpus")),
saved.getString("clientCpus", defaults?.client.orEmpty()).orEmpty(),
saved.getString("gameCpus", defaults?.game.orEmpty()).orEmpty(),
)
}
/** Use the device default until the user saves an override or a client mask. */
fun clientCpusOverride(context: Context): Boolean = coreAllocation(context).clientOverride
fun setClientCpusOverride(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("clientCpusOverride", on).apply()
}
/** Comma-separated core list for the client (taskset -c syntax); "" = every core. */
fun clientCpus(context: Context): String = coreAllocation(context).clientCpus
fun setClientCpus(context: Context, list: String) {
prefs(context).edit().putString("clientCpus", list).apply()
}
/** Comma-separated core list for games (taskset -c syntax); "" = every core (explicitly restored when the client is pinned). */
fun gameCpus(context: Context): String = coreAllocation(context).gameCpus
fun setGameCpus(context: Context, list: String) {
prefs(context).edit().putString("gameCpus", list).apply()
}
/**
* Whether Steam's xalia helper is kept out of the session (PROTON_USE_XALIA=0).
*
* xalia is an x86 Windows program Proton launches to give Windows programs gamepad navigation.
* Under FEX it cannot load the session's aarch64 preload shim, so its socket() and memfd calls
* reach the vendor's seccomp filter raw; where that answers ENOSYS - a Galaxy Fold, measured -
* it storms, and the session dies seconds after Big Picture appears. On by default: where it
* does run, it sits beside every game under FEX for nothing a controller-first session needs
* (about 10% of a core beside Once Upon a KATAMARI on an SD 8 Gen 2).
*/
fun noXalia(context: Context): Boolean = prefs(context).getBoolean("noXalia", true)
fun setNoXalia(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("noXalia", on).apply()
}
fun fastSync(context: Context): Boolean = prefs(context).getBoolean("fastSync", false)
fun setFastSync(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("fastSync", on).apply()
}
fun fsyncFirst(context: Context): Boolean = prefs(context).getBoolean("fsyncFirst", false)
fun setFsyncFirst(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("fsyncFirst", on).apply()
}
const val SYNC_ESYNC = "esync"
const val SYNC_NTSYNC = "ntsync"
const val SYNC_FSYNC = "fsync"
const val SYNC_WINESERVER = "wineserver"
/**
* The sync Proton games use, as the three switches above decide it: droiddeck-ntsync wins
* while it is on, then droiddeck-fsync first, then droiddeck-esync, and wineserver alone while all three are off.
*/
fun syncBackend(context: Context): String = syncBackendOf(fastSync(context), fsyncFirst(context), syncFallback(context))
fun syncBackendOf(fastSync: Boolean, fsyncFirst: Boolean, syncFallback: Boolean): String = when {
fastSync -> SYNC_NTSYNC
fsyncFirst -> SYNC_FSYNC
syncFallback -> SYNC_ESYNC
else -> SYNC_WINESERVER
}
/** Picks one sync for Proton games; the switches change together, in one write. */
fun setSyncBackend(context: Context, backend: String) {
require(backend == SYNC_ESYNC || backend == SYNC_NTSYNC || backend == SYNC_FSYNC || backend == SYNC_WINESERVER) { "unknown sync $backend" }
prefs(context).edit()
.putBoolean("fastSync", backend == SYNC_NTSYNC)
.putBoolean("fsyncFirst", backend == SYNC_FSYNC)
.putBoolean("syncFallback", backend != SYNC_WINESERVER)
.apply()
}
fun syncFallback(context: Context): Boolean = prefs(context).getBoolean("syncFallback", true)
fun setSyncFallback(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("syncFallback", on).apply()
}
/**
* Whether gamescope asks for realtime-priority Vulkan queues (GAMESCOPE_FORCE_VULKAN_REALTIME=1,
* which the app's gamescope build honours without CAP_SYS_NICE). Off by default, as in
* Bannerlator's session: the compositor's queue preempting the game's buys nothing on a device
* whose GPU is waiting on the CPU.
*/
fun gamescopeRealtime(context: Context): Boolean = prefs(context).getBoolean("gamescopeRealtime", false)
fun setGamescopeRealtime(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("gamescopeRealtime", on).apply()
}
/**
* Whether proot runs without its seccomp acceleration (PROOT_NO_SECCOMP=1).
*
* proot normally installs a seccomp filter so only the syscalls it must rewrite stop in the
* tracer; everything else runs untraced, which is most of proot's speed. Where a vendor kernel
* handles that filter badly the wrong calls are trapped or refused - ENOSYS from calls that
* plainly exist is the signature - and the fallback is to trace everything instead: slower,
* but correct. Max's advice for devices whose kernels "don't work well with it".
*/
/** Hold the GPU at its top clock during a session (GpuClockPin). Off by default: power and heat. */
fun gpuClockPin(context: Context): Boolean = prefs(context).getBoolean("gpuClockPin", false)
fun setGpuClockPin(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("gpuClockPin", on).apply()
}
fun prootNoSeccomp(context: Context): Boolean = prefs(context).getBoolean("prootNoSeccomp", false)
fun setProotNoSeccomp(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("prootNoSeccomp", on).apply()
}
/**
* Whether the session's path lookups take proot's fast path (ProotFastPath): answered inside
* each process instead of a round trip through the tracer. On by default; it needs proot's
* seccomp filter, so it is off whenever proot runs without one.
*/
fun prootFastPath(context: Context): Boolean = prefs(context).getBoolean("prootFastPath", true)
fun setProotFastPath(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("prootFastPath", on).apply()
}
const val DEFAULT_GUEST_HOSTNAME = "DroidDeck"
@JvmStatic
fun guestHostname(context: Context): String =
validGuestHostname(prefs(context).getString("guestHostname", null)) ?: DEFAULT_GUEST_HOSTNAME
fun setGuestHostname(context: Context, name: String) {
val valid = validGuestHostname(name)
prefs(context).edit().apply { if (valid == null) remove("guestHostname") else putString("guestHostname", valid) }.apply()
}
fun validGuestHostname(name: String?): String? {
val trimmed = name?.trim() ?: return null
return trimmed.takeIf { Regex("[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?").matches(it) }
}
/**
* Turnip's sysmem rendering (TU_DEBUG=sysmem) for the runtime's driver: bypasses GMEM tiling.
* On by default, as WinNative runs every Linux session: Chromium -> ANGLE -> Zink draws the
* client's interface as many small render passes, each paying GMEM's load/store and binning,
* and WinNative's A/B on an Adreno 840 put a game ahead with it too (Palworld 42.8 against 41.3).
*/
fun tuSysmem(context: Context): Boolean = prefs(context).getBoolean("tuSysmem", true)
fun setTuSysmem(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("tuSysmem", on).apply()
}
/*
* The client's interface is drawn Chromium -> ANGLE -> Zink -> Turnip, and that chain is what
* limits its menus (~14 fps on an Adreno 840 while a game ran 89). These make the chain
* cheaper rather than asking for more; the three environment switches are on by default,
* Deck mode off. Bannerlator's LinuxTuning, carried over; none device-proven here yet.
*/
/** mesa_glthread=true: GL marshalled off the calling thread. */
fun glThread(context: Context): Boolean = prefs(context).getBoolean("glThread", true)
fun setGlThread(context: Context, on: Boolean) { prefs(context).edit().putBoolean("glThread", on).apply() }
/** MESA_NO_ERROR=1: no GL error checking. */
fun noGlError(context: Context): Boolean = prefs(context).getBoolean("noGlError", true)
fun setNoGlError(context: Context, on: Boolean) { prefs(context).edit().putBoolean("noGlError", on).apply() }
/**
* What the pad is to the Steam client: [CONTROLLER_DECK], a Steam Deck controller (Quick Access
* button, gyro, Steam Input's full treatment - SteamDeckPad), [CONTROLLER_STEAM], a 2025 Steam
* Controller (the same, plus grip sense and its own haptics), [CONTROLLER_DS4] or
* [CONTROLLER_DUALSENSE], a PlayStation pad with its touchpad fed by the trackpads, or
* [CONTROLLER_XBOX360], the plain Xbox 360 pad of earlier versions (QAM by the Guide+A chord).
*/
fun steamController(context: Context): String =
prefs(context).getString("steamController", CONTROLLER_DECK) ?: CONTROLLER_DECK
fun setSteamController(context: Context, id: String) { prefs(context).edit().putString("steamController", id).apply() }
/** [steamController]'s choice as libfakeinput's pad type (FakeInputWriter.PAD_TYPE_*). */
fun padType(id: String): Int = when (id) {
CONTROLLER_STEAM -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_STEAM
CONTROLLER_XBOX360 -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_XBOX
CONTROLLER_DS4 -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DS4
CONTROLLER_DUALSENSE -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DUALSENSE
else -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DECK
}
/**
* Deck mode's performance overlay (mangoapp, beside gamescope): the QAM's Overlay Level draws
* through it. Off is the way out where Valve's mangoapp crashes (one Turnip build did).
*/
fun mangoapp(context: Context): Boolean = prefs(context).getBoolean("mangoapp", true)
fun setMangoapp(context: Context, on: Boolean) { prefs(context).edit().putBoolean("mangoapp", on).apply() }
/**
* Zink's lazy descriptor mode (ZINK_DESCRIPTORS=lazy) with its compact set layout
* (ZINK_DEBUG=compact) for the client's GL-on-Vulkan UI, as WinNative runs it. On by default.
*/
fun zinkLazy(context: Context): Boolean = prefs(context).getBoolean("zinkLazy", true)
fun setZinkLazy(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("zinkLazy", on).apply()
}
/** The Android driver the compositor loads: "" = pick by GPU, else a bundled or imported id. */
@JvmStatic
fun androidDriver(context: Context): String = prefs(context).getString("androidDriver", "") ?: ""
@JvmStatic
fun setAndroidDriver(context: Context, id: String) {
prefs(context).edit().putString("androidDriver", id).apply()
}
// ── Storage the session can see ─────────────────────────────────────────────────────────────
/**
* The folder on this device that every session shows at `/root/ROMs`, for the emulators on the
* desktop. "" = none chosen. All of internal storage is at `/root/Storage` regardless.
*/
fun romsDir(context: Context): String =
prefs(context).getString("romsDir", "") ?: ""
fun setRomsDir(context: Context, path: String) {
prefs(context).edit().putString("romsDir", path).apply()
}
/**
* Whether a session keeps its folder under files/logs. Off, the same logs are kept in
* the app's cache for the session's lifetime (the scripts need somewhere to write) and thrown
* away at the end, so nothing accumulates in Downloads.
*/
fun logsEnabled(context: Context): Boolean =
prefs(context).getBoolean("logs", true)
fun setLogsEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("logs", on).apply()
}
/** Steam storage-call diagnostics are opt-in because they add timing work to file operations. */
fun storageDiagnosticsEnabled(context: Context): Boolean =
prefs(context).getBoolean("storageDiagnostics", false)
fun setStorageDiagnosticsEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("storageDiagnostics", on).apply()
}
// ── Per-mode display ────────────────────────────────────────────────────────────────────
/**
* The tallest the session's display may be, in pixels, for MODE_STEAM / MODE_DESKTOP:
* 0 = the panel's own height, otherwise a cap. Both modes default to 720p: the client's CEF
* is the heaviest thing in a session, Big Picture is drawn for a TV at arm's length, and on
* a handheld panel 720p is where its menus stay responsive on a regular flagship; the desktop
* and the emulators under it get the same GPU headroom. Read once, when the session's display
* is sized; a cap the user chose wins over the default.
*/
private fun resolutionCap(context: Context, mode: String): Int = prefs(context).getInt("resolutionCap.$mode", 720)
/** Whether the user chose the mode's resolution (a cap or a custom size) rather than the default. */
fun resolutionChosen(context: Context, mode: String): Boolean =
prefs(context).contains("displayResolution.$mode") ||
prefs(context).contains("resolutionCap.$mode") || customResolution(context, mode) != null
/** One per-mode choice now owns both dimensions; old caps/shapes are read only for migration. */
fun resolutionChoice(context: Context, mode: String, panel: Pair<Int, Int>): String {
val saved = prefs(context)
saved.getString("displayResolution.$mode", null)?.let { value ->
if (value == SessionDisplay.MATCH_SCREEN || value == SessionDisplay.FOLLOW_SCREEN ||
SessionDisplay.presetHeight(value) != null) return value
parseResolution(value)?.let { return "${it.first}x${it.second}" }
}
if (!resolutionChosen(context, mode) && !saved.contains("shape")) return SessionDisplay.DEFAULT_RESOLUTION
val legacy = SessionDisplay.resolve(panel, resolutionCap(context, mode), shapeMode(context), customResolution(context, mode))
return if (legacy == SessionDisplay.screenSize(panel)) SessionDisplay.MATCH_SCREEN
else "${legacy.first}x${legacy.second}"
}
fun setResolutionChoice(context: Context, mode: String, choice: String) {
val value = if (choice == SessionDisplay.MATCH_SCREEN || choice == SessionDisplay.FOLLOW_SCREEN ||
SessionDisplay.presetHeight(choice) != null) choice else {
val size = requireNotNull(parseResolution(choice)) { "Invalid resolution" }
"${size.first}x${size.second}"
}
prefs(context).edit().putString("displayResolution.$mode", value).apply()
}
/**
* The FEXCore preset for the games the client launches (core/FexPreset ids); "" = FEX's defaults.
* Performance + TSO unless chosen, WinNative's default: FEX's own defaults keep half-barrier TSO
* and full-precision x87, which cost every x86 game time; a game that needs them can still be
* given another preset.
*/
fun fexPreset(context: Context): String = prefs(context).getString("fexPreset", DEFAULT_FEX_PRESET) ?: DEFAULT_FEX_PRESET
private const val DEFAULT_FEX_PRESET = "PERFORMANCE_TSO"
/**
* Force SSBS for Proton games: Wine resumes threads from a Windows CONTEXT that never carries
* PSTATE.SSBS, so they run with speculative store bypass disabled; libssbs.so keeps it set
* (on DiRT 3 / GE-Proton: from ~99% of a game's threads running without it to none). The
* speed-up is reported on Oryon cores (Snapdragon 8 Elite) and was not measurable on an
* 8 Gen 3, so it is off unless turned on. A game's own environment can still say
* DROIDDECK_FORCE_SSBS=0.
*/
fun forceSsbs(context: Context): Boolean = prefs(context).getBoolean("forceSsbs", false)
fun setForceSsbs(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("forceSsbs", on).apply()
runCatching { GameEnvironmentStore.publish(context) }
.onFailure { android.util.Log.e("GameEnvironment", "Could not update game environment", it) }
}
fun setFexPreset(context: Context, id: String) {
prefs(context).edit().putString("fexPreset", id).apply()
runCatching { GameEnvironmentStore.publish(context) }
.onFailure { android.util.Log.e("GameEnvironment", "Could not update game environment", it) }
}
/** Whether opening DroidDeck starts a Steam session instead of showing the front end. */
fun runSteamAtStartup(context: Context): Boolean = prefs(context).getBoolean("runSteamAtStartup", false)
fun setRunSteamAtStartup(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("runSteamAtStartup", on).apply()
}
/**
* The Games folders earlier builds let the user add (one game per subfolder); read only by
* GamesFolderMigration, which turns them into entries and clears them. The single folder an
* even earlier build kept is carried in.
*/
fun addedGamesDirs(context: Context): List<String> {
val p = prefs(context)
val list = p.getString("addedGamesDirs", null)
if (list != null) return list.split('\n').filter { it.isNotEmpty() }
val old = p.getString("addedGamesDir", "") ?: ""
return if (old.isEmpty()) emptyList() else listOf(old)
}
fun setAddedGamesDirs(context: Context, dirs: List<String>) {
prefs(context).edit().putString("addedGamesDirs", dirs.distinct().joinToString("\n")).remove("addedGamesDir").apply()
}
/**
* The Games folders earlier builds had (host path, guest path), still bound into the session so
* their games keep their paths; written once by GamesFolderMigration.
*/
fun gameFolderBinds(context: Context): List<Pair<String, String>> =
(prefs(context).getString("gameFolderBinds", "") ?: "").split('\n').mapNotNull { line ->
line.split('\t').takeIf { it.size == 2 && it[0].isNotEmpty() && it[1].isNotEmpty() }?.let { it[0] to it[1] }
}
fun setGameFolderBinds(context: Context, binds: List<Pair<String, String>>) {
prefs(context).edit().putString("gameFolderBinds", binds.distinctBy { it.first }.joinToString("\n") { "${it.first}\t${it.second}" }).apply()
}
/** The Games folders were turned into entries (GamesFolderMigration); a record, not a gate. */
fun gamesFoldersMigrated(context: Context): Boolean = prefs(context).getBoolean("gamesFoldersMigrated", false)
fun setGamesFoldersMigrated(context: Context) {
prefs(context).edit().putBoolean("gamesFoldersMigrated", true).apply()
}
/**
* Game folders (by path) the user removed from the added games: a scan skips them, so they
* stay off the Games tab and out of Steam until restored. Nothing on disk is touched.
*/
fun removedAddedGames(context: Context): List<String> =
(prefs(context).getString("removedAddedGames", "") ?: "").split('\n').filter { it.isNotEmpty() }
fun setAddedGameRemoved(context: Context, folderPath: String, removed: Boolean) {
synchronized(addedGameLock) {
val now = removedAddedGames(context).filter { it != folderPath } + listOfNotNull(folderPath.takeIf { removed })
prefs(context).edit().putString("removedAddedGames", now.joinToString("\n")).apply()
}
}
/** An added game's name as the user set it (in the app, or in Steam's Properties); "" = automatic. */
fun addedGameName(context: Context, folderPath: String): String = prefs(context).getString("addedName:$folderPath", "") ?: ""
fun setAddedGameName(context: Context, folderPath: String, name: String) {
prefs(context).edit().putString("addedName:$folderPath", name.trim()).apply()
}
/** An added game's Start in, as the session sees it; "" = the exe's own folder. */
fun addedGameStartIn(context: Context, folderPath: String): String = prefs(context).getString("addedStartIn:$folderPath", "") ?: ""
fun setAddedGameStartIn(context: Context, folderPath: String, guestDir: String) {
prefs(context).edit().putString("addedStartIn:$folderPath", guestDir.trim()).apply()
}
/** An added game's launch options, as Steam takes them ("-dx11", "VAR=1 %command%"). */
fun addedGameLaunch(context: Context, folderPath: String): String = prefs(context).getString("addedLaunch:$folderPath", "") ?: ""
fun setAddedGameLaunch(context: Context, folderPath: String, options: String) {
prefs(context).edit().putString("addedLaunch:$folderPath", options.trim()).apply()
}
/**
* The last value of [field] (AppName, StartDir, LaunchOptions) taken over from Steam's own
* Properties for this game, so the same Steam edit is not taken again over a later app edit.
*/
fun addedGameAdopted(context: Context, folderPath: String, field: String): String? =
prefs(context).getString("addedAdopted:$field:$folderPath", null)
fun setAddedGameAdopted(context: Context, folderPath: String, field: String, value: String) {
prefs(context).edit().putString("addedAdopted:$field:$folderPath", value).apply()
}
/** Where an added game's art for [slot] comes from when the user chose it ("folder", "steam", "sgdb", "file"); "" = automatic. */
fun addedGameArtSource(context: Context, folderPath: String, slot: String): String =
prefs(context).getString("addedArt:$slot:$folderPath", "") ?: ""
fun setAddedGameArtSource(context: Context, folderPath: String, slot: String, source: String) {
prefs(context).edit().putString("addedArt:$slot:$folderPath", source).apply()
}
/** Whether added games without art of their own get Steam's store art fetched for them. */
fun addedGamesArt(context: Context): Boolean = prefs(context).getBoolean("addedGamesArt", true)
fun setAddedGamesArt(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("addedGamesArt", on).apply()
}
/** The .exe the user chose for one game folder (by its path), "" = the scanner's pick. */
fun addedGameExe(context: Context, folderPath: String): String = prefs(context).getString("addedExe:$folderPath", "") ?: ""
private val addedGameLock = Any()
fun setAddedGameExe(context: Context, folderPath: String, path: String) {
synchronized(addedGameLock) { prefs(context).edit().putString("addedExe:$folderPath", path).apply() }
}
fun addedGameExeSeen(context: Context, folderPath: String): Int = prefs(context).getInt("addedExeSeen:$folderPath", 0)
fun adoptAddedGameExe(context: Context, folderPath: String, expected: String, path: String, seen: Int): Boolean =
synchronized(addedGameLock) {
val unchanged = addedGameExe(context, folderPath) == expected
if (unchanged) prefs(context).edit().putString("addedExe:$folderPath", path).putInt("addedExeSeen:$folderPath", seen).apply()
unchanged
}
/** The shortcut appid stored for the folder, or null before its first scan. */
fun addedGameStoredAppId(context: Context, folderPath: String): Long? =
prefs(context).getLong("addedAppId:$folderPath", 0L).takeIf { it != 0L }
fun addedGameAppId(context: Context, folderPath: String, first: Long): Long = synchronized(addedGameLock) {
val p = prefs(context)
p.getLong("addedAppId:$folderPath", 0L).takeIf { it != 0L }
?: first.also { p.edit().putLong("addedAppId:$folderPath", it).apply() }
}
/** The app's colour theme (ui/Themes ids); Graphite unless chosen otherwise. */
fun theme(context: Context): String = prefs(context).getString("theme", "graphite") ?: "graphite"
fun setTheme(context: Context, id: String) {
prefs(context).edit().putString("theme", id).apply()
}
/**
* A fixed size for the session's display, per mode, or null. When set it replaces both the
* cap and the shape: the compositor fits it to the panel with bars where the shapes differ.
*/
private fun customResolution(context: Context, mode: String): Pair<Int, Int>? =
parseResolution(prefs(context).getString("customRes.$mode", null))
/** "1024x768" (or ×, or *) to an even size inside 320x240..3840x2160; anything else is null. */
fun parseResolution(text: String?): Pair<Int, Int>? {
val parts = text?.trim()?.split('x', 'X', '×', '*')?.map { it.trim() } ?: return null
if (parts.size != 2) return null
val w = parts[0].toIntOrNull() ?: return null
val h = parts[1].toIntOrNull() ?: return null
if (w !in 320..3840 || h !in 240..2160) return null
return Pair(w and 1.inv(), h and 1.inv())
}
/**
* HDR10 output for MODE_STEAM / MODE_DESKTOP. Off by default. Honoured only when the panel
* lists HDR10 (HdrSupport), and decided when the compositor starts, which is once per app
* process: a change applies after the app is fully closed and opened again.
*/
fun hdr(context: Context, mode: String): Boolean = prefs(context).getBoolean("hdr.$mode", false)
fun setHdr(context: Context, mode: String, on: Boolean) {
prefs(context).edit().putBoolean("hdr.$mode", on).apply()
}
/**
* The session's frame cap, 0 for none. One number used everywhere a frame is paced: gamescope's
* -r (what the client and its games see as the display's rate), the compositor's buffer release
* pacer, the rate the display layer votes for, and the panel mode picked, which is the fastest
* one the cap divides evenly (40 on a 120 Hz panel, not on a 144 Hz one). A 60 fps cap on a
* 144 Hz panel with nothing else changed judders; this is what WinNative's per-shortcut limit
* does. Applies next session.
*/
fun fpsLimit(context: Context, mode: String): Int = prefs(context).getInt("fpsLimit.$mode", 0)
fun setFpsLimit(context: Context, mode: String, fps: Int) {
prefs(context).edit().putInt("fpsLimit.$mode", fps.coerceAtLeast(0)).apply()
}
val fpsLimitChoices = listOf(0 to "Off", 30 to "30", 40 to "40", 45 to "45", 60 to "60", 90 to "90", 120 to "120")
/** [fpsLimitChoices] labelled in the app's language. */
fun fpsLimitChoices(context: Context): List<Pair<Int, String>> =
fpsLimitChoices.map { (fps, label) -> fps to if (fps == 0) context.getString(R.string.frame_gen_off) else label }
/**
* How the compositor resizes the session onto the panel (WaylandCompositor.nativeSetUpscaler's
* modes). Linear is the default; Nearest preserves hard pixel edges. Spatial filters work
* when enlarged; Sharpen only works at any size. The old Off/Linear and FSR/FSR Fit pairs
* are equivalent on Wayland, so saved aliases resolve to one choice.
*/
val upscalerChoices = listOf(
0 to "Linear", 2 to "Nearest", 4 to "AMD FSR 1", 3 to "Snapdragon GSR",
8 to "Snapdragon GSR (quality)", 7 to "NVIDIA NIS", 6 to "Sharpen only",
)
/** The [upscalerChoices] labels that are words rather than product names. */
private val upscalerLabels = mapOf(
0 to R.string.sprefs_upscaler_linear, 2 to R.string.sprefs_upscaler_nearest,
8 to R.string.sprefs_upscaler_gsr_quality, 6 to R.string.sprefs_upscaler_sharpen,
)
/** [upscalerChoices] labelled in the app's language; the English list stays for the device report. */
fun upscalerChoices(context: Context): List<Pair<Int, String>> =
upscalerChoices.map { (mode, label) -> mode to (upscalerLabels[mode]?.let(context::getString) ?: label) }
fun canonicalUpscaler(mode: Int): Int = when (mode) {
1 -> 0
5 -> 4
else -> mode.takeIf { m -> upscalerChoices.any { it.first == m } } ?: 0
}
fun upscalerHasSharpness(mode: Int): Boolean = canonicalUpscaler(mode) in 3..8
fun upscaler(context: Context): Int = canonicalUpscaler(prefs(context).getInt("upscaler", 0))
fun setUpscaler(context: Context, mode: Int) {
prefs(context).edit().putInt("upscaler", canonicalUpscaler(mode)).apply()
}
fun upscaleSharpness(context: Context): Int = prefs(context).getInt("upscaleSharpness", 75).coerceIn(0, 100)
fun setUpscaleSharpness(context: Context, pct: Int) {
prefs(context).edit().putInt("upscaleSharpness", pct.coerceIn(0, 100)).apply()
}
// ── Screen effects and texture filtering (the Display page) ─────────────────────────────
/** The compositor's post chain as last set; off until the user picks a Look or moves a row. */
fun screenEffects(context: Context): ScreenEffects {
val text = prefs(context).getString("screenEffects", null) ?: return ScreenEffects.OFF
return runCatching { ScreenEffects.decode(JSONObject(text)) }.getOrDefault(ScreenEffects.OFF)
}
fun setScreenEffects(context: Context, effects: ScreenEffects) {
prefs(context).edit().putString("screenEffects", effects.encode().toString()).apply()
}
/** The key [cardboard] lives under; SessionActivity watches it so a change applies live. */
const val CARDBOARD_KEY = "cardboard"
/** Cardboard mode (gpu/Cardboard) as last set; off by default. */
fun cardboard(context: Context): Cardboard {
val text = prefs(context).getString(CARDBOARD_KEY, null) ?: return Cardboard.OFF
return runCatching { Cardboard.decode(JSONObject(text)) }.getOrDefault(Cardboard.OFF)
}
fun setCardboard(context: Context, cardboard: Cardboard) {
prefs(context).edit().putString(CARDBOARD_KEY, cardboard.encode().toString()).apply()
}
/** The Cardboard picture (Cardboard.SOURCE_*) to go back to once SteamVR stops; -1 = none (it was
* not switched for SteamVR). Kept across activity restarts, so a SteamVR session that outlives the
* activity still hands the picture back. */
fun cardboardSourceBeforeSteamVr(context: Context): Int = prefs(context).getInt("cardboardSourceBeforeSteamVr", -1)
fun setCardboardSourceBeforeSteamVr(context: Context, source: Int) {
prefs(context).edit().putInt("cardboardSourceBeforeSteamVr", source).apply()
}
/** The prefs file [cardboard] lives in, for a change listener. */
fun sessionPrefs(context: Context): android.content.SharedPreferences = prefs(context)
val textureAnisotropyChoices = TextureFiltering.ANISOTROPY.map { it to if (it == 0) "Off" else "${it}x" }
/** [textureAnisotropyChoices] labelled in the app's language. */
fun textureAnisotropyChoices(context: Context): List<Pair<Int, String>> =
textureAnisotropyChoices.map { (value, label) -> value to if (value == 0) context.getString(R.string.frame_gen_off) else label }
val textureLodBiasChoices = TextureFiltering.LOD_BIAS.map {
it to when (it) {
TextureFiltering.LOD_BIAS_OFF -> "Off"
TextureFiltering.LOD_BIAS_AUTO -> "Auto (match scaling)"
else -> it
}
}
/** [textureLodBiasChoices] labelled in the app's language. */
fun textureLodBiasChoices(context: Context): List<Pair<String, String>> = textureLodBiasChoices.map { (value, label) ->
value to when (value) {
TextureFiltering.LOD_BIAS_OFF -> context.getString(R.string.frame_gen_off)
TextureFiltering.LOD_BIAS_AUTO -> context.getString(R.string.sprefs_lod_bias_auto)
else -> label
}
}
/** Anisotropic filtering forced on DirectX 9-11 games (core/TextureFiltering); 0 = the game's own. */
fun textureAnisotropy(context: Context): Int =
prefs(context).getInt("textureAnisotropy", 0).takeIf { it in TextureFiltering.ANISOTROPY } ?: 0
fun setTextureAnisotropy(context: Context, value: Int) {
prefs(context).edit().putInt("textureAnisotropy", value).apply()
publishGameEnvironment(context)
}
/** Texture sharpness: the mip LOD bias choice (TextureFiltering.LOD_BIAS), "0" = the game's own. */
fun textureLodBias(context: Context): String =
prefs(context).getString("textureLodBias", null)?.takeIf { it in TextureFiltering.LOD_BIAS } ?: TextureFiltering.LOD_BIAS_OFF
fun setTextureLodBias(context: Context, choice: String) {
prefs(context).edit().putString("textureLodBias", choice).apply()
publishGameEnvironment(context)
}
fun gameTextureFiltering(context: Context, scope: String): GameTextureFiltering {
require(GameEnvironment.validScope(scope))
return gameTextureFiltering(context)[scope] ?: GameTextureFiltering()
}
fun gameTextureFiltering(context: Context): Map<String, GameTextureFiltering> {
val json = runCatching {
JSONObject(prefs(context).getString("gameTextureFiltering", null) ?: "{}")
}.getOrElse { JSONObject() }
return buildMap {
json.keys().forEach { scope ->
if (!GameEnvironment.validScope(scope)) return@forEach
val entry = json.optJSONObject(scope) ?: return@forEach
val anisotropy = entry.optInt("anisotropy").takeIf {
entry.has("anisotropy") && it in TextureFiltering.ANISOTROPY
}
val lodBias = entry.optString("lodBias").takeIf {
entry.has("lodBias") && it in TextureFiltering.LOD_BIAS
}
if (anisotropy != null || lodBias != null) {
put(scope, GameTextureFiltering(anisotropy, lodBias))
}
}
}
}
fun setGameTextureAnisotropy(context: Context, scope: String, value: Int?) {
require(value == null || value in TextureFiltering.ANISOTROPY)
updateGameTextureFiltering(context, scope) { it.copy(anisotropy = value) }
}
fun setGameTextureLodBias(context: Context, scope: String, choice: String?) {
require(choice == null || choice in TextureFiltering.LOD_BIAS)
updateGameTextureFiltering(context, scope) { it.copy(lodBias = choice) }
}
fun clearGameTextureFiltering(context: Context, scope: String) {
require(GameEnvironment.validScope(scope))
val choices = gameTextureFiltering(context).toMutableMap()
if (choices.remove(scope) == null) return
saveGameTextureFiltering(context, choices)
publishGameEnvironment(context)
}
private fun updateGameTextureFiltering(
context: Context,
scope: String,
change: (GameTextureFiltering) -> GameTextureFiltering,
) {
require(GameEnvironment.validScope(scope))
val choices = gameTextureFiltering(context).toMutableMap()
val next = change(choices[scope] ?: GameTextureFiltering())
if (next.anisotropy == null && next.lodBias == null) choices.remove(scope) else choices[scope] = next
saveGameTextureFiltering(context, choices)
publishGameEnvironment(context)
}
private fun saveGameTextureFiltering(context: Context, choices: Map<String, GameTextureFiltering>) {
val json = JSONObject()
choices.forEach { (id, choice) ->
json.put(id, JSONObject().apply {
choice.anisotropy?.let { put("anisotropy", it) }
choice.lodBias?.let { put("lodBias", it) }
})
}
prefs(context).edit().putString("gameTextureFiltering", json.toString()).apply()
}
/** Hands the change to the next game launch (GameEnvironmentStore); the running game keeps its own. */
private fun publishGameEnvironment(context: Context) {
runCatching { GameEnvironmentStore.publish(context) }
.onFailure { android.util.Log.e("GameEnvironment", "Could not update game environment", it) }
}
/**
* The mode whose per-mode settings apply: a program run under gamescope (MODE_RUN) is a
* fullscreen session like Steam's, so it takes Steam's display, driver and HDR choices.
*/
fun prefMode(mode: String): String = if (mode == SessionService.MODE_RUN) SessionService.MODE_STEAM else mode
fun pipAutoEnter(context: Context): Boolean = prefs(context).getBoolean("pipAutoEnter", false)
fun setPipAutoEnter(context: Context, enabled: Boolean) {
prefs(context).edit().putBoolean("pipAutoEnter", enabled).apply()
}
/** A foldable's unfolded panel shows the game above the Deck controls (SessionDisplay.unfoldedGameHeight). */
fun unfoldedControls(context: Context): Boolean = prefs(context).getBoolean("unfoldedControls", false)
fun setUnfoldedControls(context: Context, enabled: Boolean) {
prefs(context).edit().putBoolean("unfoldedControls", enabled).apply()
}
const val SURFACE_STICK = "stick"
const val SURFACE_TRACKPAD = "trackpad"
/** The Deck controls' gamepad tab: what each side is - an analog stick or a Steam trackpad.
* Left a stick and right a trackpad until changed, as a Deck is mostly played. */
fun gamepadSurface(context: Context, right: Boolean): String =
prefs(context).getString(if (right) "gamepadRight" else "gamepadLeft", null)
?: if (right) SURFACE_TRACKPAD else SURFACE_STICK
fun setGamepadSurface(context: Context, right: Boolean, surface: String) {
prefs(context).edit().putString(if (right) "gamepadRight" else "gamepadLeft", surface).apply()
}
fun suspendPolicy(context: Context, mode: String): String =
prefs(context).getString("suspendPolicy.${prefMode(mode)}", SUSPEND_MANUAL)
?.let { if (it == LEGACY_SUSPEND_DOWNLOADS) SUSPEND_AUTO else it }
?.takeIf { it in setOf(SUSPEND_AUTO, SUSPEND_NATIVE, SUSPEND_MANUAL, SUSPEND_NEVER) }
// Direct games share Steam's settings but have no Steam client to prepare.
?.let { if (mode != SessionService.MODE_STEAM && it == SUSPEND_NATIVE) SUSPEND_AUTO else it }
?: SUSPEND_MANUAL
fun setSuspendPolicy(context: Context, mode: String, policy: String) {
val normalized = policy.takeIf { it in setOf(SUSPEND_AUTO, SUSPEND_MANUAL, SUSPEND_NEVER) ||
(it == SUSPEND_NATIVE && mode == SessionService.MODE_STEAM) }
?: SUSPEND_MANUAL
val prefs = prefs(context)
val key = "suspendPolicy.${prefMode(mode)}"
prefs.edit().apply {
if (mode == SessionService.MODE_STEAM && prefs.getString(key, null) == LEGACY_SUSPEND_DOWNLOADS) {
putBoolean(STEAM_DOWNLOADS_IN_BACKGROUND, true)
}
putString(key, normalized)
}.apply()
}
fun steamDownloadsInBackground(context: Context): Boolean {
val prefs = prefs(context)
return prefs.getBoolean(STEAM_DOWNLOADS_IN_BACKGROUND, false) ||
prefs.getString("suspendPolicy.${SessionService.MODE_STEAM}", null) == LEGACY_SUSPEND_DOWNLOADS
}
fun setSteamDownloadsInBackground(context: Context, enabled: Boolean) {
val prefs = prefs(context)
val policyKey = "suspendPolicy.${SessionService.MODE_STEAM}"
prefs.edit().apply {
putBoolean(STEAM_DOWNLOADS_IN_BACKGROUND, enabled)
if (prefs.getString(policyKey, null) == LEGACY_SUSPEND_DOWNLOADS) {
putString(policyKey, SUSPEND_AUTO)
}
}.apply()
}
/** Steam only: the client's memory goes to the device's zram while a game runs (ZramSupport). */
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()
}
// ── Game storage ────────────────────────────────────────────────────────────────────────
/**
* A second Steam library on this device: the folder bound at /mnt/droiddeck-sd and
* registered with the client, which then asks where to install every game and shows both
* on its Storage page. "" = automatic: the SD card when one is in the phone (the default,
* so the choice is made inside the client like anywhere else); GAME_STORAGE_OFF = internal
* only; otherwise the folder chosen.
*/
fun gameStorage(context: Context): String = prefs(context).getString("gameStorage", "") ?: ""
const val GAME_STORAGE_OFF = "off"
fun gameStorageLabel(context: Context): String = prefs(context).getString("gameStorageLabel", "SD Card") ?: "SD Card"
fun setGameStorage(context: Context, path: String, label: String) {
prefs(context).edit().putString("gameStorage", path).putString("gameStorageLabel", label).apply()
}
}