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 a32dd582e..2c914cc22 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -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() } 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 86d41b2a0..8c74bcf56 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -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 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 e7ff70f31..91b544067 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -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, ) } 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 cf05c2a5f..3af2dd80d 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -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, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d3594e894..e9df59289 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -263,6 +263,8 @@ Background behavior When the app is hidden or the screen turns off. Manual + Native + Auto pauses and resumes when shown. Native prepares Steam for sleep first. Manual waits for Resume. Never keeps running. 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 new file mode 100644 index 000000000..e2c99f5e7 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionBackgroundPrefsTest.kt @@ -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)) + } +} diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-login1 b/tools/linuxfs/overlay/usr/local/bin/droiddeck-login1 index 990dd9378..ce74b0b50 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-login1 +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-login1 @@ -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 = """ @@ -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 diff --git a/tools/tests/test_login1.py b/tools/tests/test_login1.py index 4cbdc1c36..947fd8eeb 100644 --- a/tools/tests/test_login1.py +++ b/tools/tests/test_login1.py @@ -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()