Add Native Steam background sleep and automatic wake

This commit is contained in:
xXJsonDeruloXx committed 2026-10-05 16:55:56 -04:00
1 parent da29919b5a
commit b98d5a5e85
8 files changed
+244 -17

No files matched your search

@@ -8,6 +8,7 @@ import org.json.JSONObject
/** The in-session switches: the HUD and how the on-screen controls decide to appear. */
object SessionPrefs {
const val SUSPEND_AUTO = "auto"
const val SUSPEND_NATIVE = "native"
const val SUSPEND_MANUAL = "manual"
const val SUSPEND_NEVER = "never"
@@ -760,11 +761,14 @@ object SessionPrefs {
fun suspendPolicy(context: Context, mode: String): String =
prefs(context).getString("suspendPolicy.${prefMode(mode)}", SUSPEND_MANUAL)
?.takeIf { it == SUSPEND_AUTO || it == SUSPEND_MANUAL || it == SUSPEND_NEVER }
?.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 }
?: SUSPEND_MANUAL
fun setSuspendPolicy(context: Context, mode: String, policy: String) {
val normalized = policy.takeIf { it == SUSPEND_AUTO || it == SUSPEND_MANUAL || it == SUSPEND_NEVER }
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()
}
@@ -43,6 +43,7 @@ import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID
/**
* Owns the running session: the proot tree, the audio daemon, the network link and the locks that
@@ -77,6 +78,10 @@ class SessionService : Service() {
private var manualPauseRequested = false
/** An explicit Steam sleep request is independent of the background policy. */
private var steamSleepToken: String? = null
private var nativeSleepToken: String? = null
private var nativeSleepReady = false
private var nativeSleepFallback = false
private var nativeSleepTimeout: Runnable? = null
private var suspendOperationPending = false
private var pipTask = false
private var suspendAttemptFailed = false
@@ -195,6 +200,7 @@ class SessionService : Service() {
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
manualPauseRequested = false
steamSleepToken = null
completeNativeSleep()
suspendOperationPending = false
suspendAttemptFailed = false
suspendController = null
@@ -992,7 +998,8 @@ class SessionService : Service() {
activityVisible = true
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn
manualPauseRequested = false
if (steamSleepToken == null) requestSteamWake()
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake()
completeNativeSleep()
completeSteamSleep()
suspendAttemptFailed = false
updateSuspendPolicy()
@@ -1071,11 +1078,28 @@ class SessionService : Service() {
private fun watchLaunchRequests(dir: File) {
launchWatcher?.stopWatching()
listOf("steam-sleep", "steam-sleep-state", "steam-sleep-ready", "steam-wake", "steam-wake.tmp").forEach { File(dir, it).delete() }
listOf("steam-sleep", "steam-sleep-state", "steam-sleep-ready", "steam-wake", "steam-wake.tmp",
"steam-native-sleep", "steam-native-sleep.tmp", "steam-native-ready", "steam-native-ready.tmp"
).forEach { File(dir, it).delete() }
val gen = sessionGen
@Suppress("DEPRECATION")
val watcher = object : android.os.FileObserver(dir.path, CLOSE_WRITE or MOVED_TO) {
override fun onEvent(event: Int, path: String?) {
if (path == "steam-native-ready") {
val request = File(dir, path)
val token = runCatching { request.readText().trim() }.getOrNull() ?: return
request.delete()
mainHandler.post {
if (gen != sessionGen || !SessionState.running || token != nativeSleepToken) return@post
nativeSleepReady = true
nativeSleepTimeout?.let { mainHandler.removeCallbacks(it) }
nativeSleepTimeout = null
writeSteamSleepState(token, "paused")
SessionEvents.record("steam.native_sleep_prepared")
updateSuspendPolicy()
}
return
}
if (path == "steam-sleep") {
val request = File(dir, path)
val token = runCatching { request.readText().trim() }.getOrNull() ?: return
@@ -1143,18 +1167,27 @@ class SessionService : Service() {
if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && (!activityVisible || !screenOn)) {
manualPauseRequested = true
}
val controller = suspendController ?: return
val hidden = !activityVisible || !screenOn
if (suspendPolicy != SessionPrefs.SUSPEND_NATIVE || !hidden) {
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
else -> false
}
val controller = suspendController ?: return
if (suspendOperationPending || suspendAttemptFailed || shouldSuspend == SessionState.suspended) return
val gen = sessionGen
suspendOperationPending = true
SessionEvents.record(if (shouldSuspend) "session.suspend_requested" else "session.resume_requested", mapOf(
"policy" to suspendPolicy, "activityVisible" to activityVisible,
"screenOn" to screenOn, "steamSleep" to (steamSleepToken != null),
"nativeSleep" to (nativeSleepToken != null),
))
if (shouldSuspend) {
controller.freeze { success ->
@@ -1167,6 +1200,7 @@ class SessionService : Service() {
releaseLocks()
refreshNotification()
} else {
completeNativeSleep()
completeSteamSleep()
suspendAttemptFailed = true
Log.w(TAG, "could not confirm that the session stopped")
@@ -1212,12 +1246,54 @@ class SessionService : Service() {
}
private fun resumeSteamSleepOnReturn() {
if (suspendPolicy == SessionPrefs.SUSPEND_AUTO && activityVisible && screenOn) {
if (steamSleepToken == null) requestSteamWake()
if (suspendPolicy in setOf(SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE) && activityVisible && screenOn) {
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake()
completeNativeSleep()
completeSteamSleep()
}
}
/** Let Steam prepare before freezing; retain the wake lock until that freeze completes. */
private fun prepareNativeSleep() {
val token = UUID.randomUUID().toString().replace("-", "")
nativeSleepToken = token
nativeSleepReady = false
nativeSleepFallback = false
val dir = LinuxRuntime.sessionRoot(this)
runCatching {
val staged = File(dir, "steam-native-sleep.tmp")
staged.writeText("$token\n")
check(staged.renameTo(File(dir, "steam-native-sleep")))
}.onFailure { Log.w(TAG, "could not request Native sleep; using timed pause", it) }
SessionEvents.record("steam.native_sleep_requested")
val gen = sessionGen
val timeout = Runnable {
if (gen != sessionGen || !SessionState.running || nativeSleepToken != token) return@Runnable
nativeSleepTimeout = null
nativeSleepReady = true
nativeSleepFallback = true
writeSteamSleepState(token, "paused")
SessionEvents.record("steam.native_sleep_timeout")
updateSuspendPolicy()
}
nativeSleepTimeout = timeout
// A missing or stalled guest helper must not keep the device awake indefinitely.
mainHandler.postDelayed(timeout, 4000L)
}
private fun completeNativeSleep() {
nativeSleepTimeout?.let { mainHandler.removeCallbacks(it) }
nativeSleepTimeout = null
val token = nativeSleepToken ?: return
writeSteamSleepState(token, "awake")
val dir = LinuxRuntime.sessionRoot(this)
File(dir, "steam-native-sleep").delete()
File(dir, "steam-native-ready").delete()
nativeSleepToken = null
nativeSleepReady = false
nativeSleepFallback = false
}
/** Resume must also recover a Steam sleep whose request never reached Android. */
private fun requestSteamWake() {
if (!SessionState.running || SessionState.mode != MODE_STEAM) return
@@ -1281,6 +1357,7 @@ class SessionService : Service() {
private fun stopSession(status: Int) {
cancelQueuedSteamGame()
completeNativeSleep()
synchronized(stopLock) {
if (!SessionState.running) return
SessionState.running = false
@@ -288,9 +288,9 @@ 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(),
s.suspendPolicy,
note = stringResource(R.string.mode_suspend_note),
note = stringResource(if (steam) R.string.mode_suspend_steam_note else R.string.mode_suspend_note),
onPick = a.onSuspendPolicy,
)
}
@@ -507,9 +507,9 @@ 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(),
a.suspendPolicy,
note = stringResource(R.string.mode_suspend_note),
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,
)
+2
View File
@@ -263,6 +263,8 @@
<string name="mode_suspend">Background behavior</string>
<string name="mode_suspend_hint">When the app is hidden or the screen turns off.</string>
<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_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>
@@ -0,0 +1,36 @@
package com.droiddeck.launcher.session
import android.content.Context
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class SessionBackgroundPrefsTest {
private val context get() = RuntimeEnvironment.getApplication()
@Before fun clearSettings() {
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit()
}
@Test fun nativeIsOptInAndDoesNotChangeDesktopOrExistingPolicies() {
assertEquals(SessionPrefs.SUSPEND_MANUAL, SessionPrefs.suspendPolicy(context, "steam"))
for (policy in listOf(SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_MANUAL, SessionPrefs.SUSPEND_NEVER,
SessionPrefs.SUSPEND_NATIVE)) {
SessionPrefs.setSuspendPolicy(context, "steam", policy)
assertEquals(policy, SessionPrefs.suspendPolicy(context, "steam"))
assertEquals(SessionPrefs.SUSPEND_MANUAL, SessionPrefs.suspendPolicy(context, "desktop"))
}
}
@Test fun directGamesUseAutoWithoutOverwritingSteamsNativeChoice() {
SessionPrefs.setSuspendPolicy(context, SessionService.MODE_STEAM, SessionPrefs.SUSPEND_NATIVE)
assertEquals(SessionPrefs.SUSPEND_AUTO, SessionPrefs.suspendPolicy(context, SessionService.MODE_RUN))
assertEquals(SessionPrefs.SUSPEND_NATIVE, SessionPrefs.suspendPolicy(context, SessionService.MODE_STEAM))
}
}
@@ -7,6 +7,7 @@ must see this service before startup or it can stay black after its idle timer.
"""
import os
from pathlib import Path
import re
import sys
import uuid
@@ -20,6 +21,7 @@ ROOT = "/org/freedesktop/login1"
INTERFACE = NAME + ".Manager"
UPOWER = "org.freedesktop.UPower"
UPOWER_ROOT = "/org/freedesktop/UPower"
NATIVE_PREPARE_MS = 1500
INTROSPECTION = """
<node>
<interface name="org.freedesktop.login1.Manager">
@@ -47,8 +49,11 @@ class LoginManager:
self.request = self.directory / "steam-sleep"
self.state = self.directory / "steam-sleep-state"
self.wake = self.directory / "steam-wake"
self.native_request = self.directory / "steam-native-sleep"
self.native_ready = self.directory / "steam-native-ready"
self.pending = None
self.timeout = None
self.preparation = None
info = Gio.DBusNodeInfo.new_for_xml(INTROSPECTION).interfaces[0]
self.registration = bus.register_object(ROOT, info, self.method, self.property, None)
# Steam can issue its suspend command through the legacy UPower service even
@@ -83,29 +88,69 @@ class LoginManager:
reply.return_value(None)
# Flush the signal and method reply without blocking this D-Bus callback. Once both are
# on the wire, publishing the request lets the host safely freeze this service.
self.bus.flush(None, self.publish_after_flush, None)
self.bus.flush(None, self.publish_after_flush, self.pending)
def publish_after_flush(self, connection, result, _data):
def publish_after_flush(self, connection, result, token):
try:
connection.flush_finish(result)
except GLib.Error as error:
print("== login1: cannot complete suspend handshake: " + str(error), file=sys.stderr)
self.resume()
return
self.publish()
if self.pending == token:
self.publish(self.request)
def publish(self):
def publish(self, request):
self.check_state()
if self.pending is None:
return False
try:
staged = self.request.with_suffix(".tmp")
staged = request.with_suffix(".tmp")
staged.write_text(self.pending + "\n")
staged.replace(self.request)
staged.replace(request)
self.timeout = GLib.timeout_add_seconds(5, self.check_ack)
except OSError as error:
print("== login1: cannot request pause: " + str(error), file=sys.stderr)
self.resume()
return False
def prepare_native(self):
try:
token = self.native_request.read_text().strip()
self.native_request.unlink(missing_ok=True)
except OSError:
return
if not re.fullmatch(r"[a-f0-9]{32}", token) or self.pending is not None:
return
# Android may have returned before this helper read its request.
if self.read_state() == token + ":awake":
return
self.pending = token
self.notify(True)
self.bus.flush(None, self.prepare_after_flush, token)
def prepare_after_flush(self, connection, result, token):
try:
connection.flush_finish(result)
except GLib.Error as error:
print("== login1: cannot prepare native sleep: " + str(error), file=sys.stderr)
self.resume()
return
self.check_state()
if self.pending == token:
# Keep Steam runnable while it handles the system sleep notification.
# This is a bounded grace period, not a claim that every game has saved.
self.preparation = GLib.timeout_add(NATIVE_PREPARE_MS, self.finish_preparation, token)
def finish_preparation(self, token):
self.preparation = None
if self.pending == token:
self.publish(self.native_ready)
return False
def changed(self, monitor, file, other, event):
if file.get_basename() == self.native_request.name or (other and other.get_basename() == self.native_request.name):
self.prepare_native()
if file.get_basename() == self.wake.name or (other and other.get_basename() == self.wake.name):
if self.wake.exists():
self.wake.unlink(missing_ok=True)
@@ -128,6 +173,7 @@ class LoginManager:
if self.pending and self.read_state() != self.pending + ":paused":
# A missing host or a failed freeze must not strand Steam in black.
self.request.unlink(missing_ok=True)
self.native_ready.unlink(missing_ok=True)
self.resume()
return False
@@ -140,6 +186,9 @@ class LoginManager:
self.bus.flush(None, self.finish_recovery, None)
return
self.pending = None
if self.preparation is not None:
GLib.source_remove(self.preparation)
self.preparation = None
if self.timeout is not None:
GLib.source_remove(self.timeout)
self.timeout = None
+59
View File
@@ -134,6 +134,65 @@ class SleepHandshakeTest(unittest.TestCase):
staged.write_text("wake\n")
staged.replace(self.dir / "steam-wake")
def request_native(self, token="1" * 32):
staged = self.dir / "steam-native-sleep.tmp"
staged.write_text(token + "\n")
staged.replace(self.dir / "steam-native-sleep")
return token
def test_native_sleep_prepares_before_host_can_freeze(self):
started = time.monotonic()
token = self.request_native()
self.wait_for(lambda: self.signals == [True])
self.assertTrue(self.preparing())
self.assertFalse((self.dir / "steam-native-ready").exists())
self.assertFalse((self.dir / "steam-sleep").exists())
self.wait_for(lambda: (self.dir / "steam-native-ready").exists())
self.assertGreaterEqual(time.monotonic() - started, 1.4)
self.assertEqual((self.dir / "steam-native-ready").read_text().strip(), token)
self.state(token, "paused")
self.state(token, "awake")
self.wait_for(lambda: self.signals == [True, False])
self.assertFalse(self.preparing())
def test_return_during_native_preparation_cancels_late_pause(self):
token = self.request_native()
self.wait_for(lambda: self.signals == [True])
self.state(token, "awake")
self.wait_for(lambda: self.signals == [True, False])
time.sleep(1.7)
self.assertFalse((self.dir / "steam-native-ready").exists())
self.assertFalse(self.preparing())
def test_native_request_cancelled_before_helper_reads_it(self):
os.kill(self.process.pid, signal.SIGSTOP)
token = self.request_native()
self.state(token, "awake")
os.kill(self.process.pid, signal.SIGCONT)
self.wait_for(lambda: not (self.dir / "steam-native-sleep").exists())
time.sleep(1.7)
self.assertFalse((self.dir / "steam-native-ready").exists())
self.assertFalse(self.preparing())
self.assertEqual(self.signals, [])
def test_second_native_sleep_cannot_receive_first_preparation_callback(self):
first = self.request_native()
self.wait_for(lambda: self.signals == [True])
self.state(first, "awake")
self.wait_for(lambda: self.signals == [True, False])
self.signals.clear()
second = self.request_native("2" * 32)
self.wait_for(lambda: (self.dir / "steam-native-ready").exists())
self.assertEqual((self.dir / "steam-native-ready").read_text().strip(), second)
self.state(second, "awake")
self.wait_for(lambda: self.signals == [True, False])
def test_native_sleep_with_missing_host_recovers(self):
self.request_native()
self.wait_for(lambda: self.signals == [True, False], timeout=9)
self.assertFalse(self.preparing())
self.assertFalse((self.dir / "steam-native-ready").exists())
def test_host_resume_recovers_without_a_sleep_request(self):
self.assertFalse(self.preparing())
self.request_wake()