Add Steam background download mode (#335)

* Add Steam background download mode

* Keep active Steam downloads running

* Make background downloads a toggle

* fix: keep background downloads alive between Steam's rate log lines

* Track all Steam content update types

---------

Co-authored-by: xXJsonDeruloXx <[email protected]>
This commit is contained in:
Juan MartíandxXJsonDeruloXx authored and GitHub committed 2026-10-07 10:36:49 +00:00
1 parent bf17ce1151
commit 206c8a8846
10 files changed
+450 -10

No files matched your search

@@ -330,6 +330,7 @@ class MainActivity : ComponentActivity() {
private var hdrReason by mutableStateOf<String?>(null)
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
private var steamDownloadsInBackground by mutableStateOf(false)
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
private var backActionsInverted by mutableStateOf(false)
private var renderer by mutableStateOf("vulkan")
@@ -1163,6 +1164,7 @@ class MainActivity : ComponentActivity() {
gpuDrivers = drivers.summary(),
touchMode = touchMode,
suspendPolicy = suspendPolicy,
steamDownloadsInBackground = steamDownloadsInBackground,
pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this),
pipAutoEnter = pipAutoEnter,
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
@@ -1222,6 +1224,10 @@ class MainActivity : ComponentActivity() {
onUpscaleSharpness = { pct -> SessionPrefs.setUpscaleSharpness(this, pct); upscaleSharpness = pct },
onTouch = { t -> SessionPrefs.setTouchMode(this, t); touchMode = t },
onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this, mode, policy); suspendPolicy = policy },
onSteamDownloadsInBackground = { enabled ->
SessionPrefs.setSteamDownloadsInBackground(this, enabled)
steamDownloadsInBackground = enabled
},
onPipAutoEnter = { on -> SessionPrefs.setPipAutoEnter(this, on); pipAutoEnter = on },
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
onBackActionsInverted = { inverted ->
@@ -1390,6 +1396,7 @@ class MainActivity : ComponentActivity() {
hdrReason = com.droiddeck.launcher.wayland.HdrSupport.probe(this).reason
touchMode = SessionPrefs.touchMode(this)
suspendPolicy = SessionPrefs.suspendPolicy(this, mode)
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
oscMode = SessionPrefs.oscMode(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
directAudio = SessionPrefs.directAudio(this)
@@ -246,6 +246,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var frameGen by mutableStateOf(FrameGen.Mode.OFF)
private var fexPreset by mutableStateOf("")
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
private var steamDownloadsInBackground by mutableStateOf(false)
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
private var onScreenButtonsVisible by mutableStateOf(false)
private var secondScreenMode by mutableStateOf(SessionState.secondScreenMode)
@@ -510,7 +511,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
effects = effects, textureAnisotropy = textureAnisotropy, textureLodBias = textureLodBias,
frameGen = frameGen,
lossless = lossless,
oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy, touchMode = touchMode,
oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy,
steamDownloadsInBackground = steamDownloadsInBackground, touchMode = touchMode,
touchAuto = getString(if (usingTouchpad()) R.string.session_touch_auto_touchpad else R.string.session_touch_auto_direct),
fexPreset = fexPreset,
secondScreenMode = secondScreenMode,
@@ -562,6 +564,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
suspendPolicy = policy
SessionService.suspendPolicyChanged(this@SessionActivity)
},
onSteamDownloadsInBackground = { enabled ->
SessionPrefs.setSteamDownloadsInBackground(this@SessionActivity, enabled)
steamDownloadsInBackground = enabled
SessionService.suspendPolicyChanged(this@SessionActivity)
},
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
onSecondScreenMode = ::selectSecondScreenMode,
@@ -789,6 +796,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
frameGen = FrameGen.mode(this)
fexPreset = SessionPrefs.fexPreset(this)
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
oscMode = SessionPrefs.oscMode(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
}
@@ -13,6 +13,8 @@ object SessionPrefs {
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"
@@ -779,16 +781,41 @@ object SessionPrefs {
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 (it == SUSPEND_NATIVE && mode != SessionService.MODE_STEAM) SUSPEND_AUTO else it }
?.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
prefs(context).edit().putString("suspendPolicy.${prefMode(mode)}", normalized).apply()
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()
}
// ── Game storage ────────────────────────────────────────────────────────────────────────
@@ -13,6 +13,7 @@ import android.content.BroadcastReceiver
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.net.TrafficStats
import android.net.wifi.WifiManager
import android.os.Build
import android.os.Environment
@@ -75,7 +76,12 @@ class SessionService : Service() {
private val auxiliaryProcessesLock = Any()
private val mainHandler = Handler(Looper.getMainLooper())
private var suspendController: SessionSuspendController? = null
private val steamDownloadMonitor = SteamDownloadMonitor()
private var downloadMonitorTask: Runnable? = null
private var downloadActive = false
private var suspendDownloadNow = false
private var suspendPolicy = SessionPrefs.SUSPEND_MANUAL
private var steamDownloadsInBackground = false
private var activityVisible = true
private var screenOn = true
private var manualPauseRequested = false
@@ -167,6 +173,7 @@ class SessionService : Service() {
ACTION_ACTIVITY_VISIBLE -> {
if (!SessionState.pipActive) pipTask = false
activityVisible = true
suspendDownloadNow = false
resumeSteamSleepOnReturn()
suspendAttemptFailed = false
updateSuspendPolicy()
@@ -181,10 +188,18 @@ class SessionService : Service() {
ACTION_SUSPEND_POLICY_CHANGED -> {
if (!SessionState.running) return START_NOT_STICKY
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
updateDownloadMonitoring()
suspendAttemptFailed = false
updateSuspendPolicy()
return START_NOT_STICKY
}
ACTION_SUSPEND_NOW -> {
if (!SessionState.running) return START_NOT_STICKY
suspendDownloadNow = true
updateSuspendPolicy()
return START_NOT_STICKY
}
ACTION_RESUME -> {
resumeSession()
return START_NOT_STICKY
@@ -199,9 +214,13 @@ class SessionService : Service() {
}
SessionState.mode = intent?.getStringExtra(EXTRA_MODE) ?: MODE_STEAM
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
activityVisible = true
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
manualPauseRequested = false
suspendDownloadNow = false
steamDownloadMonitor.reset()
downloadActive = false
steamSleepToken = null
completeNativeSleep()
suspendOperationPending = false
@@ -525,6 +544,7 @@ class SessionService : Service() {
},
)
mainHandler.post { updateSuspendPolicy() }
updateDownloadMonitoring()
}
}
@@ -1017,6 +1037,7 @@ class SessionService : Service() {
activityVisible = true
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn
manualPauseRequested = false
suspendDownloadNow = false
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake()
completeNativeSleep()
completeSteamSleep()
@@ -1188,16 +1209,19 @@ class SessionService : Service() {
}
val controller = suspendController ?: return
val hidden = !activityVisible || !screenOn
if (suspendPolicy != SessionPrefs.SUSPEND_NATIVE || !hidden) {
val downloadsBlockingSuspend = steamDownloadsInBackground && SessionState.mode == MODE_STEAM &&
suspendPolicy != SessionPrefs.SUSPEND_NEVER && hidden && downloadActive && !suspendDownloadNow
if (downloadsBlockingSuspend) acquireLocks()
if (suspendPolicy != SessionPrefs.SUSPEND_NATIVE || !hidden || downloadsBlockingSuspend) {
completeNativeSleep()
} else if (nativeSleepToken == null && steamSleepToken == null &&
!SessionState.suspended && !suspendAttemptFailed) {
prepareNativeSleep()
}
val shouldSuspend = steamSleepToken != null || when (suspendPolicy) {
SessionPrefs.SUSPEND_AUTO -> !activityVisible || !screenOn
SessionPrefs.SUSPEND_NATIVE -> hidden && nativeSleepReady
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested
SessionPrefs.SUSPEND_AUTO -> hidden && !downloadsBlockingSuspend
SessionPrefs.SUSPEND_NATIVE -> hidden && nativeSleepReady && !downloadsBlockingSuspend
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && !downloadsBlockingSuspend
else -> false
}
if (suspendOperationPending || suspendAttemptFailed || shouldSuspend == SessionState.suspended) return
@@ -1381,6 +1405,7 @@ class SessionService : Service() {
if (!SessionState.running) return
SessionState.running = false
}
updateDownloadMonitoring()
val stoppedGen = sessionGen
SessionState.stopRequested = false
SessionEvents.record("guest.exited", mapOf("status" to status))
@@ -1549,11 +1574,26 @@ class SessionService : Service() {
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_session)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(if (SessionState.suspended) R.string.session_notification_paused else R.string.session_notification))
.setContentText(getString(
when {
SessionState.suspended -> R.string.session_notification_paused
downloadActive && steamDownloadsInBackground &&
suspendPolicy != SessionPrefs.SUSPEND_NEVER -> R.string.session_notification_downloads
else -> R.string.session_notification
}
))
.setContentIntent(open)
.apply {
if (SessionState.suspended) {
addAction(Notification.Action.Builder(null, getString(R.string.resume_session), resume).build())
} else if (downloadActive && steamDownloadsInBackground &&
suspendPolicy != SessionPrefs.SUSPEND_NEVER) {
val suspend = PendingIntent.getService(
this@SessionService, 3,
Intent(this@SessionService, SessionService::class.java).setAction(ACTION_SUSPEND_NOW),
PendingIntent.FLAG_IMMUTABLE,
)
addAction(Notification.Action.Builder(null, getString(R.string.suspend_now), suspend).build())
}
}
.addAction(Notification.Action.Builder(null, getString(R.string.stop_session), stop).build())
@@ -1567,6 +1607,40 @@ class SessionService : Service() {
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, buildNotification())
}
private fun updateDownloadMonitoring() {
val enabled = SessionState.running && SessionState.mode == MODE_STEAM &&
steamDownloadsInBackground && suspendPolicy != SessionPrefs.SUSPEND_NEVER
if (!enabled) {
val wasActive = downloadActive
downloadMonitorTask?.let(mainHandler::removeCallbacks)
downloadMonitorTask = null
steamDownloadMonitor.reset()
downloadActive = false
if (wasActive) refreshNotification()
return
}
if (downloadMonitorTask != null) return
val poll = object : Runnable {
override fun run() {
if (downloadMonitorTask !== this || !SessionState.running ||
SessionState.mode != MODE_STEAM || !steamDownloadsInBackground ||
suspendPolicy == SessionPrefs.SUSPEND_NEVER) {
return
}
val wasActive = downloadActive
downloadActive = steamDownloadMonitor.poll(
LinuxRuntime.rootDir(this@SessionService),
receivedBytes = TrafficStats.getUidRxBytes(android.os.Process.myUid()),
)
if (wasActive != downloadActive) refreshNotification()
updateSuspendPolicy()
mainHandler.postDelayed(this, DOWNLOAD_POLL_MS)
}
}
downloadMonitorTask = poll
mainHandler.post(poll)
}
/** The pads attached as the session starts; the activity logs the ones that come and go after. */
private fun logControllersAtStart() {
val pads = ArrayList<String>()
@@ -1591,6 +1665,7 @@ class SessionService : Service() {
private const val NOTIFICATION_ID = 1001
const val ACTION_STOP = "com.droiddeck.launcher.STOP_SESSION"
const val ACTION_RESUME = "com.droiddeck.launcher.RESUME_SESSION"
const val ACTION_SUSPEND_NOW = "com.droiddeck.launcher.SUSPEND_NOW"
private const val ACTION_PIP_BEGIN = "com.droiddeck.launcher.PIP_BEGIN"
const val ACTION_LAUNCH_GAME = "com.droiddeck.launcher.LAUNCH_STEAM_GAME"
const val ACTION_HOME_GUIDE = "com.droiddeck.launcher.HOME_GUIDE"
@@ -1610,6 +1685,7 @@ class SessionService : Service() {
private const val STEAM_PICKUP_MS = 1500L
private const val STEAM_EXIT_MS = 10_000L
private const val GAME_LAUNCH_RETRY_MS = 250L
private const val DOWNLOAD_POLL_MS = 2_000L
private const val STEAM_GAME_REQUEST = "steam-game"
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
private const val NO_UINPUT_SWITCH = "Download/droiddeck-no-uinput"
@@ -0,0 +1,138 @@
package com.droiddeck.launcher.session
import java.io.File
import java.io.RandomAccessFile
internal class SteamDownloadMonitor {
private data class ManifestState(
val bytesDownloaded: Long,
val incomplete: Boolean,
)
private var previous = emptyMap<String, ManifestState>()
private var lastProgressAt = 0L
private var contentLogOffset = 0L
private val activeContentUpdates = mutableSetOf<String>()
private var lastContentActivityAt = 0L
private var lastReceivedBytes = -1L
private var lastReceivedAt = 0L
/**
* [receivedBytes] is the app uid's received-byte counter (the guest runs as that uid), or a
* negative value when Android does not report one. Steam writes its rate line about once a
* minute and flushes manifest counters even less often, so live traffic is what bridges them.
*/
fun poll(runtimeRoot: File, now: Long = System.currentTimeMillis(), receivedBytes: Long = -1L): Boolean {
val current = manifests(runtimeRoot).associate { it.path to read(it) }
val progressed = current.any { (path, state) ->
state.incomplete && state.bytesDownloaded > (previous[path]?.bytesDownloaded ?: state.bytesDownloaded)
}
if (progressed) lastProgressAt = now
previous = current
val manifestActive = progressed || lastProgressAt > 0L && now - lastProgressAt <= MANIFEST_PROGRESS_GRACE_MS &&
current.values.any { it.incomplete }
if (receiving(receivedBytes, now)) lastContentActivityAt = now
return manifestActive || readContentActivity(runtimeRoot, now)
}
private fun receiving(receivedBytes: Long, now: Long): Boolean {
val previousBytes = lastReceivedBytes
val previousAt = lastReceivedAt
lastReceivedBytes = receivedBytes
lastReceivedAt = now
if (receivedBytes < 0L || previousBytes < 0L || receivedBytes < previousBytes || now <= previousAt) return false
return (receivedBytes - previousBytes) * 1000L / (now - previousAt) >= MIN_RECEIVE_BYTES_PER_SEC
}
fun reset() {
previous = emptyMap()
lastProgressAt = 0L
contentLogOffset = 0L
activeContentUpdates.clear()
lastContentActivityAt = 0L
lastReceivedBytes = -1L
lastReceivedAt = 0L
}
private fun readContentActivity(runtimeRoot: File, now: Long): Boolean {
val log = File(runtimeRoot, "root/.local/share/Steam/logs/content_log.txt")
if (!log.isFile) return false
if (log.length() < contentLogOffset) {
contentLogOffset = 0L
activeContentUpdates.clear()
lastContentActivityAt = 0L
}
runCatching {
RandomAccessFile(log, "r").use { input ->
input.seek(contentLogOffset)
while (true) {
val line = input.readLine() ?: break
when {
"Client version:" in line -> {
activeContentUpdates.clear()
lastContentActivityAt = 0L
}
CONTENT_UPDATE.find(line)?.let { match ->
val appId = match.groupValues[1]
val updateType = match.groupValues[2]
val state = match.groupValues[3]
val updateId = "$updateType:$appId"
if ("Running Update" in state && "Stopping" !in state) {
activeContentUpdates += updateId
lastContentActivityAt = now
} else {
activeContentUpdates -= updateId
}
true
} == true -> Unit
CURRENT_RATE.find(line)?.groupValues?.get(1)?.toDoubleOrNull()?.let { it > 0.0 } == true ->
lastContentActivityAt = now
"Downloading " in line && " chunks for depot " in line ->
lastContentActivityAt = now
}
if (activeContentUpdates.isNotEmpty()) lastContentActivityAt = now
}
contentLogOffset = input.filePointer
}
}
return activeContentUpdates.isNotEmpty() && lastContentActivityAt > 0L &&
now - lastContentActivityAt <= CONTENT_ACTIVITY_GRACE_MS
}
private fun manifests(runtimeRoot: File): List<File> {
val steamRoots = linkedSetOf(File(runtimeRoot, "root/.local/share/Steam"))
val primary = steamRoots.first()
val libraryFolders = File(primary, "steamapps/libraryfolders.vdf")
runCatching {
PATH.findAll(libraryFolders.readText()).forEach { match ->
steamRoots += File(runtimeRoot, match.groupValues[1].removePrefix("/"))
}
}
return steamRoots.flatMap { root ->
File(root, "steamapps").listFiles { file ->
file.isFile && file.name.startsWith("appmanifest_") && file.name.endsWith(".acf")
}.orEmpty().toList()
}
}
private fun read(file: File): ManifestState {
val text = runCatching { file.readText() }.getOrDefault("")
val flags = value(text, "StateFlags")?.toLongOrNull() ?: 0L
val downloaded = value(text, "BytesDownloaded")?.toLongOrNull() ?: 0L
val total = value(text, "BytesToDownload")?.toLongOrNull() ?: 0L
return ManifestState(downloaded, flags != 4L && (total <= 0L || downloaded < total))
}
private fun value(text: String, key: String): String? =
Regex("\"$key\"\\s+\"([^\"]*)\"").find(text)?.groupValues?.get(1)
private companion object {
const val MANIFEST_PROGRESS_GRACE_MS = 8_000L
// Longer than Steam's ~60 s between rate lines, for when no traffic counter is available.
const val CONTENT_ACTIVITY_GRACE_MS = 90_000L
const val MIN_RECEIVE_BYTES_PER_SEC = 64L * 1024L
val PATH = Regex("\"path\"\\s+\"([^\"]+)\"")
val CONTENT_UPDATE = Regex("""AppID (\d+) (App|Workshop|Shader) update changed : (.*)""")
val CURRENT_RATE = Regex("""Current download rate: ([\d.]+) Mbps""")
}
}
@@ -67,6 +67,7 @@ class ModeSettings(
val upscaleSharpness: Int = 75,
val touchMode: String,
val suspendPolicy: String,
val steamDownloadsInBackground: Boolean = false,
val pipSupported: Boolean = false,
val pipAutoEnter: Boolean = false,
/** Steam only. */
@@ -134,6 +135,7 @@ class ModeSettingsActions(
val onUpscaleSharpness: (Int) -> Unit = {},
val onTouch: (String) -> Unit,
val onSuspendPolicy: (String) -> Unit,
val onSteamDownloadsInBackground: (Boolean) -> Unit = {},
val onPipAutoEnter: (Boolean) -> Unit = {},
val onOsc: (String) -> Unit,
val onBackActionsInverted: (Boolean) -> Unit = {},
@@ -302,11 +304,18 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never),
) + if (steam) listOf(SessionPrefs.SUSPEND_NATIVE to stringResource(R.string.mode_suspend_native)) else emptyList(),
) + if (steam) listOf(
SessionPrefs.SUSPEND_NATIVE to stringResource(R.string.mode_suspend_native),
) else emptyList(),
s.suspendPolicy,
note = stringResource(if (steam) R.string.mode_suspend_steam_note else R.string.mode_suspend_note),
onPick = a.onSuspendPolicy,
)
if (steam && s.suspendPolicy != SessionPrefs.SUSPEND_NEVER) ToggleRow(
host, "background-downloads", stringResource(R.string.mode_background_downloads),
stringResource(R.string.mode_background_downloads_hint),
s.steamDownloadsInBackground, onChange = a.onSteamDownloadsInBackground,
)
}
if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) {
ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null,
@@ -191,6 +191,7 @@ class DrawerActions(
val oscMode: String,
val onScreenButtonsVisible: Boolean,
val suspendPolicy: String,
val steamDownloadsInBackground: Boolean = false,
val backActionsInverted: Boolean,
val touchMode: String,
val touchAuto: String,
@@ -221,6 +222,7 @@ class DrawerActions(
val onQamButton: (Boolean) -> Unit = {},
val onKeyboardButton: (Boolean) -> Unit = {},
val onSuspendPolicy: (String) -> Unit,
val onSteamDownloadsInBackground: (Boolean) -> Unit = {},
val onBackActionsInverted: (Boolean) -> Unit,
val onTouch: (String) -> Unit,
val onFexPreset: (String) -> Unit,
@@ -499,12 +501,20 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never),
) + if (a.steam) listOf(SessionPrefs.SUSPEND_NATIVE to stringResource(R.string.mode_suspend_native)) else emptyList(),
) + if (a.steam) listOf(
SessionPrefs.SUSPEND_NATIVE to stringResource(R.string.mode_suspend_native),
) else emptyList(),
a.suspendPolicy,
note = stringResource(if (a.steam) R.string.mode_suspend_steam_note else R.string.mode_suspend_note),
chipModifier = focus.track(page, "suspend"),
onPick = a.onSuspendPolicy,
)
if (a.steam && a.suspendPolicy != SessionPrefs.SUSPEND_NEVER) ToggleRow(
host, "background-downloads", stringResource(R.string.mode_background_downloads),
stringResource(R.string.mode_background_downloads_hint),
a.steamDownloadsInBackground,
onChange = a.onSteamDownloadsInBackground,
)
}
SettingsGroup(stringResource(R.string.drawer_support)) {
SettingsRow(stringResource(R.string.drawer_logs), stringResource(R.string.drawer_logs_hint)) {
+4
View File
@@ -15,8 +15,10 @@
<string name="session_channel">Session</string>
<string name="session_channel_description">Shows while a Steam or desktop session is open</string>
<string name="session_notification">Steam is running</string>
<string name="session_notification_downloads">Steam download active</string>
<string name="session_notification_paused">Session paused - tap Resume when ready</string>
<string name="resume_session">Resume</string>
<string name="suspend_now">Suspend now</string>
<string name="stop_session">Stop session</string>
<string name="frame_gen_title">Frame generation</string>
<string name="frame_gen_hint">Select the frame generation mode</string>
@@ -267,6 +269,8 @@
<string name="mode_suspend_manual">Manual</string>
<string name="mode_suspend_native">Native</string>
<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_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>
@@ -33,4 +33,27 @@ class SessionBackgroundPrefsTest {
assertEquals(SessionPrefs.SUSPEND_AUTO, SessionPrefs.suspendPolicy(context, SessionService.MODE_RUN))
assertEquals(SessionPrefs.SUSPEND_NATIVE, SessionPrefs.suspendPolicy(context, SessionService.MODE_STEAM))
}
@Test fun backgroundDownloadsAreOptInAndMigrateTheTestBuildPolicy() {
assertEquals(false, SessionPrefs.steamDownloadsInBackground(context))
SessionPrefs.setSteamDownloadsInBackground(context, true)
assertEquals(true, SessionPrefs.steamDownloadsInBackground(context))
clearSettings()
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit()
.putString("suspendPolicy.steam", "downloads").commit()
assertEquals(SessionPrefs.SUSPEND_AUTO, SessionPrefs.suspendPolicy(context, SessionService.MODE_STEAM))
assertEquals(true, SessionPrefs.steamDownloadsInBackground(context))
SessionPrefs.setSuspendPolicy(context, SessionService.MODE_STEAM, SessionPrefs.SUSPEND_MANUAL)
assertEquals(SessionPrefs.SUSPEND_MANUAL, SessionPrefs.suspendPolicy(context, SessionService.MODE_STEAM))
assertEquals(true, SessionPrefs.steamDownloadsInBackground(context))
clearSettings()
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit()
.putString("suspendPolicy.steam", "downloads").commit()
SessionPrefs.setSteamDownloadsInBackground(context, false)
assertEquals(SessionPrefs.SUSPEND_AUTO, SessionPrefs.suspendPolicy(context, SessionService.MODE_STEAM))
assertEquals(false, SessionPrefs.steamDownloadsInBackground(context))
}
}
@@ -0,0 +1,138 @@
package com.droiddeck.launcher.session
import java.io.File
import java.nio.file.Files
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SteamDownloadMonitorTest {
@Test fun reportsOnlyProgressingIncompleteManifests() {
val root = Files.createTempDirectory("droiddeck-steam-download-").toFile()
try {
val steam = File(root, "root/.local/share/Steam")
val manifest = manifest(steam, "42", downloaded = 10, total = 100, flags = 6)
val monitor = SteamDownloadMonitor()
assertFalse(monitor.poll(root, now = 1_000L))
manifest.writeText(appManifest(downloaded = 20, total = 100, flags = 6))
assertTrue(monitor.poll(root, now = 3_000L))
assertTrue(monitor.poll(root, now = 9_000L))
assertFalse(monitor.poll(root, now = 11_001L))
} finally {
root.deleteRecursively()
}
}
@Test fun findsDownloadsInAdditionalSteamLibraries() {
val root = Files.createTempDirectory("droiddeck-steam-download-").toFile()
try {
val library = File(root, "mnt/droiddeck-sd").apply { mkdirs() }
File(root, "root/.local/share/Steam/steamapps").apply { mkdirs() }
.resolve("libraryfolders.vdf")
.writeText("\"libraryfolders\" { \"1\" { \"path\" \"/mnt/droiddeck-sd\" } }")
val manifest = manifest(library, "43", downloaded = 1, total = 2, flags = 6)
val monitor = SteamDownloadMonitor()
assertFalse(monitor.poll(root, now = 1_000L))
manifest.writeText(appManifest(downloaded = 2, total = 2, flags = 4))
assertFalse(monitor.poll(root, now = 3_000L))
} finally {
root.deleteRecursively()
}
}
@Test fun followsSteamContentActivityWhenManifestProgressIsNotFlushed() {
val root = Files.createTempDirectory("droiddeck-steam-download-").toFile()
try {
val steam = File(root, "root/.local/share/Steam")
manifest(steam, "44", downloaded = 0, total = 10_000, flags = 10)
val contentLog = File(steam, "logs/content_log.txt").apply {
check(parentFile?.mkdirs() != false)
writeText("[2026-10-06 14:44:17] AppID 44 App update changed : Running Update,Downloading,Staging,\n")
}
val monitor = SteamDownloadMonitor()
assertTrue(monitor.poll(root, now = 1_000L))
assertTrue(monitor.poll(root, now = 90_000L))
assertFalse(monitor.poll(root, now = 91_001L))
contentLog.appendText("[2026-10-06 14:45:02] Current download rate: 27.178 Mbps\n")
assertTrue(monitor.poll(root, now = 92_000L))
contentLog.appendText("[2026-10-06 14:45:03] AppID 44 App update changed : None\n")
assertFalse(monitor.poll(root, now = 93_000L))
} finally {
root.deleteRecursively()
}
}
@Test fun receivedTrafficBridgesSteamRateLines() {
val root = Files.createTempDirectory("droiddeck-steam-download-").toFile()
try {
val contentLog = File(root, "root/.local/share/Steam/logs/content_log.txt").apply {
check(parentFile?.mkdirs() != false)
writeText("[2026-10-06 16:06:30] AppID 44 App update changed : Running Update,Downloading,\n")
}
val monitor = SteamDownloadMonitor()
var received = 1_000_000L
var now = 1_000L
assertTrue(monitor.poll(root, now, received))
// Five minutes of a download Steam no longer logs, at 4 MB/s.
while (now < 300_000L) {
now += 2_000L
received += 8_000_000L
assertTrue(monitor.poll(root, now, received))
}
// Traffic stops: the session may suspend once the grace runs out.
assertTrue(monitor.poll(root, now + 90_000L, received))
assertFalse(monitor.poll(root, now + 92_001L, received))
contentLog.appendText("[2026-10-06 16:12:00] AppID 44 App update changed : None\n")
assertFalse(monitor.poll(root, now + 94_000L, received + 8_000_000L))
} finally {
root.deleteRecursively()
}
}
@Test fun trafficAloneIsNotADownload() {
val root = Files.createTempDirectory("droiddeck-steam-download-").toFile()
try {
val monitor = SteamDownloadMonitor()
assertFalse(monitor.poll(root, now = 1_000L, receivedBytes = 0L))
assertFalse(monitor.poll(root, now = 3_000L, receivedBytes = 50_000_000L))
} finally {
root.deleteRecursively()
}
}
@Test fun newSteamSessionClearsStaleContentActivity() {
val root = Files.createTempDirectory("droiddeck-steam-download-").toFile()
try {
val log = File(root, "root/.local/share/Steam/logs/content_log.txt").apply {
check(parentFile?.mkdirs() != false)
writeText("[old] AppID 44 App update changed : Running Update,Downloading,\n")
appendText("[new] Client version: 1\n")
}
val monitor = SteamDownloadMonitor()
assertFalse(monitor.poll(root, now = 1_000L))
log.appendText("[new] AppID 44 App update changed : Running Update,Downloading,\n")
assertTrue(monitor.poll(root, now = 2_000L))
} finally {
root.deleteRecursively()
}
}
private fun manifest(root: File, appId: String, downloaded: Long, total: Long, flags: Int): File {
val file = File(root, "steamapps/appmanifest_$appId.acf")
check(file.parentFile?.mkdirs() != false)
file.writeText(appManifest(downloaded, total, flags))
return file
}
private fun appManifest(downloaded: Long, total: Long, flags: Int) =
"\"AppState\" { \"StateFlags\" \"$flags\" \"BytesDownloaded\" \"$downloaded\" \"BytesToDownload\" \"$total\" }"
}