diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index e963cae8..07548b4e 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -330,6 +330,7 @@ class MainActivity : ComponentActivity() { private var hdrReason by mutableStateOf(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) diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 7a417244..364bef14 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -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) } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index d140168c..214d4a0d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -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 ──────────────────────────────────────────────────────────────────────── diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 3e1e0337..8fa22768 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -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() @@ -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" diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamDownloadMonitor.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamDownloadMonitor.kt new file mode 100644 index 00000000..af8515fb --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/SteamDownloadMonitor.kt @@ -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() + private var lastProgressAt = 0L + private var contentLogOffset = 0L + private val activeContentUpdates = mutableSetOf() + 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 { + 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""") + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index 1b35cde4..c09e4070 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -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, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index 70571fbb..38cd3b41 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -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)) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index add3b112..80b8e958 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -15,8 +15,10 @@ Session Shows while a Steam or desktop session is open Steam is running + Steam download active Session paused - tap Resume when ready Resume + Suspend now Stop session Frame generation Select the frame generation mode @@ -267,6 +269,8 @@ Manual Native Auto pauses and resumes when shown. Native prepares Steam for sleep first. Manual waits for Resume. Never keeps running. + Keep downloads running + Delay background suspension while Steam is actively downloading. Auto pauses in the background and resumes when shown. Manual waits for Resume. Never keeps running. Startup Start Steam with DroidDeck diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionBackgroundPrefsTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionBackgroundPrefsTest.kt index e2c99f5e..0daf6f99 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionBackgroundPrefsTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionBackgroundPrefsTest.kt @@ -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)) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/session/SteamDownloadMonitorTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SteamDownloadMonitorTest.kt new file mode 100644 index 00000000..86cf3200 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SteamDownloadMonitorTest.kt @@ -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\" }" +}