mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
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:
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)) {
|
||||
|
||||
@@ -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\" }"
|
||||
}
|
||||
Reference in new issue
Block a user