diff --git a/README.md b/README.md index 10490adc6..ee5349834 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ On Android 12+, if Steam exits without a log, turn off **Restrict child processe ## Build -Run `tools/build_local.sh` with Docker and the Android SDK/NDK installed. The APK is written to `app/build/outputs/apk/release/app-release.apk`. To install it on an attached device, run `tools/deploy_local.sh`. +Run `tools/build_local.sh` with Docker, Java 17, the Android SDK/NDK, and `zstd` installed. It builds the ARM64 audio sinks from PulseAudio 13.0 and packages them into the APK at `app/build/outputs/apk/release/app-release.apk`. Set `STEAMDECK_PA13_SOURCE_DIR` to an existing PulseAudio 13.0 source directory to skip downloading it. To install the APK on an attached device, run `tools/deploy_local.sh`. ## Limits diff --git a/app/src/main/assets/pulseaudio.tzst b/app/src/main/assets/pulseaudio.tzst index 7e2f9e08c..60066f1a5 100644 Binary files a/app/src/main/assets/pulseaudio.tzst and b/app/src/main/assets/pulseaudio.tzst differ diff --git a/app/src/main/java/com/steamdeck/launcher/MainActivity.kt b/app/src/main/java/com/steamdeck/launcher/MainActivity.kt index 72167521e..ed1f1188d 100644 --- a/app/src/main/java/com/steamdeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/steamdeck/launcher/MainActivity.kt @@ -153,6 +153,7 @@ class MainActivity : ComponentActivity() { private var hdrOn by mutableStateOf(false) private var hdrReason by mutableStateOf(null) private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO) + private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL) private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO) private var renderer by mutableStateOf("pixman") private var gameStorage by mutableStateOf("") @@ -390,6 +391,7 @@ class MainActivity : ComponentActivity() { linuxSelected = if (mode == SessionService.MODE_STEAM) linuxSteam else linuxDesktop, androidRows = androidRows, androidSelected = androidSelected, touchMode = touchMode, + suspendPolicy = suspendPolicy, oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null, directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null, mic = if (mode == SessionService.MODE_STEAM) mic else null, @@ -413,6 +415,7 @@ class MainActivity : ComponentActivity() { onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a display driver (AdrenoTools zip)")) }, onRemoveAndroid = { id -> TurnipDriver(this).remove(id); refreshDrivers() }, onTouch = { t -> SessionPrefs.setTouchMode(this, t); touchMode = t }, + onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this, mode, policy); suspendPolicy = policy }, onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o }, onDirectAudio = { on -> SessionPrefs.setDirectAudio(this, on); directAudio = on }, onMic = { on -> @@ -503,6 +506,7 @@ class MainActivity : ComponentActivity() { hdrOn = SessionPrefs.hdr(this, mode) hdrReason = com.steamdeck.launcher.wayland.HdrSupport.probe(this).reason touchMode = SessionPrefs.touchMode(this) + suspendPolicy = SessionPrefs.suspendPolicy(this, mode) oscMode = SessionPrefs.oscMode(this) directAudio = SessionPrefs.directAudio(this) mic = SessionPrefs.micEnabled(this) diff --git a/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt b/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt index be5bb21a5..9e684f3f9 100644 --- a/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt @@ -49,6 +49,7 @@ import com.steamdeck.launcher.ui.LoadingOverlay import com.steamdeck.launcher.ui.ProtonDialog import com.steamdeck.launcher.ui.ProtonRow import com.steamdeck.launcher.ui.SessionDrawer +import com.steamdeck.launcher.ui.SessionPausedOverlay import com.steamdeck.launcher.ui.SteamDeckTheme import com.steamdeck.launcher.wayland.CompositorHost import com.steamdeck.launcher.wayland.WaylandCompositor @@ -203,6 +204,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { onRemove = { id -> ProtonExtras.tools.first { it.id == id }.let { ProtonExtras.remove(this@SessionActivity, it) }; refreshProtons() }, onDismiss = { showProtons = false }, ) + if (SessionState.suspended) SessionPausedOverlay { + SessionService.resume(this@SessionActivity) + } } } }) @@ -682,8 +686,31 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { // ── Lifecycle ─────────────────────────────────────────────────────────────────────────── + override fun onStart() { + super.onStart() + SessionService.setActivityVisible(this, true) + } + + override fun onStop() { + SessionService.setActivityVisible(this, false) + super.onStop() + } + + override fun onNewIntent(intent: android.content.Intent) { + super.onNewIntent(intent) + setIntent(intent) + if (intent.action == SessionService.ACTION_RESUME) { + intent.action = null + SessionService.resume(this) + } + } + override fun onResume() { super.onResume() + if (intent?.action == SessionService.ACTION_RESUME) { + intent.action = null + SessionService.resume(this) + } (getSystemService(INPUT_SERVICE) as? InputManager) ?.registerInputDeviceListener(deviceListener, Handler(Looper.getMainLooper())) updateOnScreenControls() diff --git a/app/src/main/java/com/steamdeck/launcher/audio/DirectAudioRelayComponent.java b/app/src/main/java/com/steamdeck/launcher/audio/DirectAudioRelayComponent.java index bca34d7a2..ab7ee987d 100644 --- a/app/src/main/java/com/steamdeck/launcher/audio/DirectAudioRelayComponent.java +++ b/app/src/main/java/com/steamdeck/launcher/audio/DirectAudioRelayComponent.java @@ -38,7 +38,7 @@ public class DirectAudioRelayComponent extends SessionPart { private File logFile; /** Null when the microphone was not asked for; the helper then opens no input stream. */ private final File micFifoPath; - private int pid = -1; + private volatile int pid = -1; public DirectAudioRelayComponent(File socketPath, File micFifoPath) { this.socketPath = socketPath; @@ -129,4 +129,9 @@ public class DirectAudioRelayComponent extends SessionPart { pid = -1; } } + + @Override + public int suspendPid() { + return pid; + } } diff --git a/app/src/main/java/com/steamdeck/launcher/audio/PulseAudioComponent.java b/app/src/main/java/com/steamdeck/launcher/audio/PulseAudioComponent.java index 152515530..acbaee62c 100644 --- a/app/src/main/java/com/steamdeck/launcher/audio/PulseAudioComponent.java +++ b/app/src/main/java/com/steamdeck/launcher/audio/PulseAudioComponent.java @@ -15,23 +15,20 @@ import java.net.URL; import java.nio.file.Files; import java.nio.file.StandardCopyOption; import java.util.ArrayList; +import java.util.concurrent.TimeUnit; /** * PulseAudio 13.0 running in the app process, with Android's AAudio as its sink, so the guest's * libpulse clients (Steam and everything it launches) have a server to talk to. The socket lives * in the app's files directory and is bound into the session at its own path, so PULSE_SERVER * needs no translating. - * - *

Ported down from Bannerlator's component: no sink suspend/resume and no route-change - * recreate - those need the pasink native client, and a session here is a foreground activity - * that does not background the way a game container does. */ public class PulseAudioComponent extends SessionPart { private static final String TAG = "PulseAudio"; /** Where the guest reaches the daemon; the session exports PULSE_SERVER=unix:. */ public static final String SOCKET_NAME = "PS0"; /** Identifies the bundled pulseaudio.tzst; a change here re-unpacks it over what a device has. */ - private static final String BUNDLE_STAMP = "2026-09-23-pa13-relay-sink-r3"; + private static final String BUNDLE_STAMP = "2026-09-23-pa13-suspend-r4"; private final File workingDir; /** Where the daemon's own output is kept for this session, or null for logcat only. */ @@ -45,7 +42,7 @@ public class PulseAudioComponent extends SessionPart { private final String micFifoPath; /** The DirectAudio relay's socket when the client's output should go through it, else null. */ private String relaySocketPath; - private int pid = -1; + private volatile int pid = -1; public PulseAudioComponent(Context context) { this(context, null); @@ -94,6 +91,7 @@ public class PulseAudioComponent extends SessionPart { File stamp = new File(modulesDir, ".bundle"); String have = FileUtils.readString(stamp); if (!new File(modulesDir, "arm64/module-aaudio-sink.so").isFile() + || !new File(workingDir, "pactl").isFile() || have == null || !BUNDLE_STAMP.equals(have.trim())) { Log.i(TAG, "unpacking pulseaudio.tzst (" + BUNDLE_STAMP + "; had " + have + ")"); FileUtils.delete(modulesDir); @@ -103,6 +101,8 @@ public class PulseAudioComponent extends SessionPart { Log.e(TAG, "pulseaudio.tzst did not unpack"); } } + File pactl = new File(workingDir, "pactl"); + if (pactl.isFile()) FileUtils.chmod(pactl, 0771); copyFromLibraryDir(); //noinspection ResultOfMethodCallIgnored @@ -169,6 +169,45 @@ public class PulseAudioComponent extends SessionPart { } } + public boolean setSinkSuspended(boolean suspended) { + File pactl = new File(workingDir, "pactl"); + if (!pactl.isFile() || pid <= 1) { + Log.w(TAG, "cannot change sink state: PulseAudio control client or server unavailable"); + return false; + } + java.lang.Process process = null; + try { + File modules = new File(workingDir, "modules/arm64"); + ProcessBuilder builder = new ProcessBuilder( + pactl.getAbsolutePath(), "suspend-sink", "@DEFAULT_SINK@", Boolean.toString(suspended)); + builder.directory(workingDir); + builder.redirectErrorStream(true); + builder.redirectOutput(new File("/dev/null")); + builder.environment().put("LD_LIBRARY_PATH", "/system/lib64:" + modules + ":" + workingDir); + builder.environment().put("HOME", workingDir.getAbsolutePath()); + builder.environment().put("TMPDIR", workingDir.getAbsolutePath()); + builder.environment().put("PULSE_SERVER", "unix:" + socket().getAbsolutePath()); + process = builder.start(); + if (!process.waitFor(5, TimeUnit.SECONDS)) { + process.destroyForcibly(); + Log.w(TAG, "timed out changing sink state to suspended=" + suspended); + return false; + } + int status = process.exitValue(); + if (status != 0) Log.w(TAG, "sink state change failed with exit " + status); + return status == 0; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + Log.w(TAG, "interrupted changing sink state", e); + return false; + } catch (Exception e) { + Log.w(TAG, "could not change sink state", e); + return false; + } finally { + if (process != null && process.isAlive()) process.destroyForcibly(); + } + } + /** The session's audio log, appended to by both the daemon and the relay helper. */ private java.io.PrintWriter openLog() { if (logFile == null) return null; diff --git a/app/src/main/java/com/steamdeck/launcher/core/SessionPart.java b/app/src/main/java/com/steamdeck/launcher/core/SessionPart.java index 9ba34e127..b65322ee7 100644 --- a/app/src/main/java/com/steamdeck/launcher/core/SessionPart.java +++ b/app/src/main/java/com/steamdeck/launcher/core/SessionPart.java @@ -23,4 +23,8 @@ public abstract class SessionPart { public abstract void start(); public abstract void stop(); + + public int suspendPid() { + return -1; + } } diff --git a/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt index 5d410e8e9..10372c47d 100644 --- a/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt @@ -4,6 +4,10 @@ import android.content.Context /** 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_MANUAL = "manual" + const val SUSPEND_NEVER = "never" + const val OSC_AUTO = "auto" const val OSC_ALWAYS = "always" const val OSC_STEAM_QAM = "steam-qam" @@ -306,6 +310,17 @@ object SessionPrefs { */ fun prefMode(mode: String): String = if (mode == SessionService.MODE_RUN) SessionService.MODE_STEAM else mode + 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 } + ?: SUSPEND_MANUAL + + fun setSuspendPolicy(context: Context, mode: String, policy: String) { + val normalized = policy.takeIf { it == SUSPEND_AUTO || it == SUSPEND_MANUAL || it == SUSPEND_NEVER } + ?: SUSPEND_MANUAL + prefs(context).edit().putString("suspendPolicy.${prefMode(mode)}", normalized).apply() + } + // ── Game storage ──────────────────────────────────────────────────────────────────────── /** diff --git a/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt b/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt index 341e05dda..9d509ad0a 100644 --- a/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt @@ -9,12 +9,16 @@ import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.Context +import android.content.BroadcastReceiver import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager import android.net.wifi.WifiManager import android.os.Build import android.os.Environment import android.os.IBinder +import android.os.Handler +import android.os.Looper import android.os.PowerManager import android.util.Log import com.steamdeck.launcher.R @@ -52,22 +56,83 @@ import java.util.Locale * The activity comes and goes on top of this; see [com.steamdeck.launcher.wayland.CompositorHost]. */ class SessionService : Service() { - private val components = ArrayList() + private val components = java.util.concurrent.CopyOnWriteArrayList() private var wakeLock: PowerManager.WakeLock? = null private var wifiLock: WifiManager.WifiLock? = null private var sessionPid = -1 + private val mainHandler = Handler(Looper.getMainLooper()) + private var suspendController: SessionSuspendController? = null + private var suspendPolicy = SessionPrefs.SUSPEND_MANUAL + private var activityVisible = true + private var screenOn = true + private var manualPauseRequested = false + private var suspendOperationPending = false + private var suspendAttemptFailed = false + private var screenReceiverRegistered = false + private val screenReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + Intent.ACTION_SCREEN_OFF -> screenOn = false + Intent.ACTION_SCREEN_ON -> screenOn = true + else -> return + } + suspendAttemptFailed = false + updateSuspendPolicy() + } + } /** Counts sessions this service has started; a process exit from an earlier one is ignored. */ private var sessionGen = 0 + override fun onCreate() { + super.onCreate() + val power = getSystemService(Context.POWER_SERVICE) as? PowerManager + screenOn = power?.isInteractive ?: true + registerReceiver(screenReceiver, IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_OFF) + addAction(Intent.ACTION_SCREEN_ON) + }) + screenReceiverRegistered = true + } + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (intent?.action == ACTION_STOP) { - Log.i(TAG, "stop requested from the notification") - stopSession(0) - return START_NOT_STICKY + when (intent?.action) { + ACTION_STOP -> { + Log.i(TAG, "stop requested from the notification") + stopSession(0) + return START_NOT_STICKY + } + ACTION_ACTIVITY_VISIBLE -> { + activityVisible = true + suspendAttemptFailed = false + updateSuspendPolicy() + return START_NOT_STICKY + } + ACTION_ACTIVITY_HIDDEN -> { + activityVisible = false + suspendAttemptFailed = false + updateSuspendPolicy() + return START_NOT_STICKY + } + ACTION_RESUME -> { + activityVisible = true + screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn + manualPauseRequested = false + suspendAttemptFailed = false + updateSuspendPolicy() + return START_NOT_STICKY + } } startForeground(NOTIFICATION_ID, buildNotification()) if (SessionState.running) return START_NOT_STICKY SessionState.mode = intent?.getStringExtra(EXTRA_MODE) ?: MODE_STEAM + suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode) + activityVisible = true + screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true + manualPauseRequested = false + suspendOperationPending = false + suspendAttemptFailed = false + suspendController = null + SessionState.suspended = false SessionState.program = intent?.getStringExtra(EXTRA_PROGRAM) SessionState.programArgs = intent?.getStringArrayExtra(EXTRA_PROGRAM_ARGS)?.toList().orEmpty() SessionState.steamUi = intent?.getStringExtra(EXTRA_STEAM_UI) @@ -460,6 +525,19 @@ class SessionService : Service() { stopSession(status ?: -1) }, null) Log.i(TAG, "session pid $sessionPid, log ${sessionLog.path}") + if (gen == sessionGen && SessionState.running) { + suspendController = SessionSuspendController( + sessionRoot = { sessionPid }, + helperRoots = { components.mapNotNull { it.suspendPid().takeIf { pid -> pid > 1 } } }, + suspendAudio = { + if (!pulse.setSinkSuspended(true)) Log.w(TAG, "could not suspend audio sink") + }, + resumeAudio = { + if (!pulse.setSinkSuspended(false)) Log.w(TAG, "could not resume audio sink") + }, + ) + mainHandler.post { updateSuspendPolicy() } + } } private fun teardown(prootPid: Int) { @@ -585,9 +663,68 @@ class SessionService : Service() { launchWatcher = watcher } + private fun updateSuspendPolicy() { + if (!SessionState.running) return + if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && (!activityVisible || !screenOn)) { + manualPauseRequested = true + } + val shouldSuspend = when (suspendPolicy) { + SessionPrefs.SUSPEND_AUTO -> !activityVisible || !screenOn + SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested + else -> false + } + val controller = suspendController ?: return + if (suspendOperationPending || suspendAttemptFailed || shouldSuspend == SessionState.suspended) return + suspendOperationPending = true + if (shouldSuspend) { + controller.freeze { success -> + mainHandler.post { + if (!SessionState.running) return@post + suspendOperationPending = false + if (success) { + SessionState.suspended = true + releaseLocks() + refreshNotification() + } else { + suspendAttemptFailed = true + Log.w(TAG, "could not confirm that the session stopped") + } + updateSuspendPolicy() + } + } + } else { + acquireLocks() + controller.resume { success -> + mainHandler.post { + if (!SessionState.running) return@post + suspendOperationPending = false + if (success) { + SessionState.suspended = false + refreshNotification() + } else { + suspendAttemptFailed = true + Log.w(TAG, "could not confirm that the session resumed") + } + updateSuspendPolicy() + } + } + } + } + + private fun finishSessionStop(status: Int) { + mainHandler.post { + SessionState.suspended = false + releaseLocks() + SessionState.notifyEnded(status) + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + private fun stopSession(status: Int) { if (!SessionState.running) return SessionState.running = false + suspendOperationPending = false launchWatcher?.stopWatching() launchWatcher = null // proot's --kill-on-exit takes the guest tree down only if proot gets to run it, and @@ -596,12 +733,11 @@ class SessionService : Service() { // for over an hour, and the reaper could not even open /proc to kill them. So: SIGTERM, // a grace for proot's own cleanup, SIGKILL only if it will not go, then a sweep of the // tree it had - snapshotted first, each pid checked against its start time so a number - // reused by a new process is never touched. Same shape as Bannerlator's fix (4509d788). - if (sessionPid != -1) { - val prootPid = sessionPid - sessionPid = -1 - Thread({ teardown(prootPid) }, "session-teardown").start() - } + // reused by a new process is never touched. + val prootPid = sessionPid + sessionPid = -1 + val controller = suspendController + suspendController = null // On its own thread, never here: stopSession runs on the main thread (the notification's // Stop action arrives there), and collecting means copying the compositor's log, scrubbing // every Steam log line by line - 42 files on one measured run - and waiting for logcat to @@ -618,10 +754,18 @@ class SessionService : Service() { } components.clear() FakeInputWriter.releaseAllRingSlots() - releaseLocks() - SessionState.notifyEnded(status) - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() + val finishAfterTeardown: () -> Unit = { + if (prootPid > 1) { + Thread({ + teardown(prootPid) + finishSessionStop(status) + }, "session-teardown").start() + } else { + finishSessionStop(status) + } + } + if (prootPid > 1) acquireLocks() + if (controller != null) controller.closeAndResume(finishAfterTeardown) else finishAfterTeardown() } /** @@ -636,6 +780,10 @@ class SessionService : Service() { override fun onDestroy() { stopSession(0) + if (screenReceiverRegistered) { + unregisterReceiver(screenReceiver) + screenReceiverRegistered = false + } super.onDestroy() } @@ -645,12 +793,12 @@ class SessionService : Service() { private fun acquireLocks() { try { - val power = getSystemService(Context.POWER_SERVICE) as? PowerManager - wakeLock = power?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "SteamDeck:session")?.apply { - setReferenceCounted(false) - // Capped, so a crash on some path cannot pin the CPU awake for good. A session - // longer than this re-acquires from the notification tap; nothing else needs it. - acquire(12L * 60L * 60L * 1000L) + if (wakeLock?.isHeld != true) { + val power = getSystemService(Context.POWER_SERVICE) as? PowerManager + wakeLock = power?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "SteamDeck:session")?.apply { + setReferenceCounted(false) + acquire(12L * 60L * 60L * 1000L) + } } Log.i(TAG, "wake lock held=${wakeLock?.isHeld}") } catch (t: Throwable) { @@ -659,11 +807,13 @@ class SessionService : Service() { try { val wifi = applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager @Suppress("DEPRECATION") // deprecated from API 29, still honoured; targetSdk is 28 - wifiLock = wifi?.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "SteamDeck:session-wifi") - ?.apply { - setReferenceCounted(false) - acquire() - } + if (wifiLock?.isHeld != true) { + wifiLock = wifi?.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "SteamDeck:session-wifi") + ?.apply { + setReferenceCounted(false) + acquire() + } + } Log.i(TAG, "wifi lock held=${wifiLock?.isHeld}") } catch (t: Throwable) { // A partial wake lock keeps the process alive but does not stop WiFi power-save from @@ -711,11 +861,23 @@ class SessionService : Service() { this, 1, Intent(this, SessionService::class.java).setAction(ACTION_STOP), PendingIntent.FLAG_IMMUTABLE, ) + val resume = PendingIntent.getActivity( + this, 2, + Intent(this, SessionActivity::class.java) + .setAction(ACTION_RESUME) + .setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT), + PendingIntent.FLAG_IMMUTABLE, + ) return Notification.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_stat_session) .setContentTitle(getString(R.string.app_name)) - .setContentText(getString(R.string.session_notification)) + .setContentText(getString(if (SessionState.suspended) R.string.session_notification_paused else R.string.session_notification)) .setContentIntent(open) + .apply { + if (SessionState.suspended) { + addAction(Notification.Action.Builder(null, getString(R.string.resume_session), resume).build()) + } + } .addAction(Notification.Action.Builder(null, getString(R.string.stop_session), stop).build()) .setOngoing(true) .setShowWhen(false) @@ -723,6 +885,10 @@ class SessionService : Service() { .build() } + private fun refreshNotification() { + getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, buildNotification()) + } + companion object { private const val TAG = "SessionService" /** Downloads file whose contents become TU_DEBUG inside the session, e.g. "sysmem". */ @@ -732,6 +898,9 @@ class SessionService : Service() { private const val CHANNEL_ID = "session" private const val NOTIFICATION_ID = 1001 const val ACTION_STOP = "com.steamdeck.launcher.STOP_SESSION" + const val ACTION_RESUME = "com.steamdeck.launcher.RESUME_SESSION" + private const val ACTION_ACTIVITY_VISIBLE = "com.steamdeck.launcher.ACTIVITY_VISIBLE" + private const val ACTION_ACTIVITY_HIDDEN = "com.steamdeck.launcher.ACTIVITY_HIDDEN" /** Command lines that can only belong to a session of ours. */ private val STRAGGLERS = listOf("bannerlator-session", "gamescope", "Xwayland", "steamrtarm64", "steamwebhelper", "linuxfs/opt/android-host/proot", "pulseaudio/libpulseaudio.so") @@ -768,5 +937,16 @@ class SessionService : Service() { fun stop(context: Context) { context.startService(Intent(context, SessionService::class.java).setAction(ACTION_STOP)) } + + fun setActivityVisible(context: Context, visible: Boolean) { + if (!SessionState.running) return + val action = if (visible) ACTION_ACTIVITY_VISIBLE else ACTION_ACTIVITY_HIDDEN + context.startService(Intent(context, SessionService::class.java).setAction(action)) + } + + fun resume(context: Context) { + if (!SessionState.running) return + context.startService(Intent(context, SessionService::class.java).setAction(ACTION_RESUME)) + } } } diff --git a/app/src/main/java/com/steamdeck/launcher/session/SessionState.kt b/app/src/main/java/com/steamdeck/launcher/session/SessionState.kt index 5210a5e80..44dfdb4a7 100644 --- a/app/src/main/java/com/steamdeck/launcher/session/SessionState.kt +++ b/app/src/main/java/com/steamdeck/launcher/session/SessionState.kt @@ -1,6 +1,9 @@ package com.steamdeck.launcher.session import java.io.File +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue /** * The little that the activity and the service have to agree on. Process-wide rather than passed @@ -10,6 +13,7 @@ import java.io.File object SessionState { @Volatile var running = false + var suspended by mutableStateOf(false) /** MODE_RUN: the program inside the runtime the session was started for. */ var program: String? = null var programArgs: List = emptyList() diff --git a/app/src/main/java/com/steamdeck/launcher/session/SessionSuspendController.kt b/app/src/main/java/com/steamdeck/launcher/session/SessionSuspendController.kt new file mode 100644 index 000000000..e882dbf74 --- /dev/null +++ b/app/src/main/java/com/steamdeck/launcher/session/SessionSuspendController.kt @@ -0,0 +1,246 @@ +package com.steamdeck.launcher.session + +import android.os.Process +import java.io.File +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +internal class SessionSuspendController( + private val sessionRoot: () -> Int, + private val helperRoots: () -> List, + private val suspendAudio: () -> Unit, + private val resumeAudio: () -> Unit, +) { + private enum class Role { SESSION_ROOT, SESSION_CHILD, HELPER_ROOT, HELPER_CHILD } + + private data class ProcessRecord( + val pid: Int, + val started: Long, + val role: Role, + ) + + private data class ProcessInfo( + val pid: Int, + val parent: Int, + val started: Long, + val state: Char, + ) + + private val executor: ExecutorService = Executors.newSingleThreadExecutor() + @Volatile private var closing = false + private var frozen = emptyList() + + fun freeze(completion: (Boolean) -> Unit) { + submit { + if (closing) { + completion(false) + return@submit + } + if (frozen.isNotEmpty()) { + val fullyFrozen = frozen.any { it.role == Role.SESSION_ROOT } && frozen.all(::isStoppedOrGone) + if (!fullyFrozen && restore(frozen)) frozen = emptyList() + completion(fullyFrozen) + return@submit + } + val records = LinkedHashMap, ProcessRecord>() + val root = sessionRoot() + if (root <= 1) { + completion(false) + return@submit + } + runAudio(suspendAudio) + val helpers = helperRoots().filter { it > 1 && it != root }.distinct() + var stablePasses = 0 + var complete = false + repeat(10) { pass -> + if (closing) { + restore(records.values.toList()) + runAudio(resumeAudio) + completion(false) + return@submit + } + val includeRoots = pass >= 2 + addSnapshot(records, root, helpers, includeRoots) + stop(records.values.toList(), includeRoots) + Thread.sleep(if (pass == 0) 70L else 35L) + val rootCaptured = records.values.any { it.role == Role.SESSION_ROOT } + if (rootCaptured && records.isNotEmpty() && records.values.all(::isStoppedOrGone)) { + stablePasses++ + if (stablePasses >= 2) complete = true + } else { + stablePasses = 0 + } + } + if (complete) { + frozen = records.values.toList() + completion(true) + } else { + val restored = restore(records.values.toList()) + runAudio(resumeAudio) + if (!restored) frozen = records.values.toList() + completion(false) + } + } + } + + fun resume(completion: (Boolean) -> Unit) { + submit { + val records = frozen + runAudio(resumeAudio) + val resumed = restore(records) + if (resumed) frozen = emptyList() + completion(resumed) + } + } + + fun closeAndResume(completion: () -> Unit) { + closing = true + submit { + restore(frozen) + frozen = emptyList() + completion() + executor.shutdown() + } + } + + private fun submit(task: () -> Unit) { + try { + executor.execute { + try { + task() + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } + } catch (_: RuntimeException) { + task() + } + } + + private fun runAudio(action: () -> Unit) { + try { + action() + } catch (_: Exception) { + } + } + + private fun addSnapshot( + records: MutableMap, ProcessRecord>, + root: Int, + helpers: List, + includeRoots: Boolean, + ) { + val processes = readProcesses() + val byPid = processes.associateBy { it.pid } + for (process in descendants(root, processes, byPid)) { + records[process.pid to process.started] = ProcessRecord(process.pid, process.started, Role.SESSION_CHILD) + } + for (helper in helpers) { + for (process in descendants(helper, processes, byPid)) { + records[process.pid to process.started] = ProcessRecord(process.pid, process.started, Role.HELPER_CHILD) + } + } + if (includeRoots) { + byPid[root]?.let { records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.SESSION_ROOT) } + for (helper in helpers) { + byPid[helper]?.let { records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.HELPER_ROOT) } + } + } + } + + private fun descendants(root: Int, processes: List, byPid: Map): List { + if (root !in byPid) return emptyList() + val children = HashMap>() + for (process in processes) children.getOrPut(process.parent) { ArrayList() }.add(process) + val excluded = Process.myPid() + val found = ArrayList() + val seen = HashSet() + val queue = ArrayDeque().apply { add(root) } + while (queue.isNotEmpty()) { + val parent = queue.removeFirst() + for (child in children[parent].orEmpty()) { + if (child.pid <= 1 || child.pid == excluded || !seen.add(child.pid)) continue + found.add(child) + queue.add(child.pid) + } + } + return found + } + + private fun readProcesses(): List = + File("/proc").listFiles().orEmpty().mapNotNull { entry -> + val pid = entry.name.toIntOrNull() ?: return@mapNotNull null + readProcess(pid) + } + + private fun readProcess(pid: Int): ProcessInfo? { + val stat = try { + File("/proc/$pid/stat").readText() + } catch (_: Exception) { + return null + } + val close = stat.lastIndexOf(')') + if (close < 0 || close + 2 >= stat.length) return null + val fields = stat.substring(close + 2).trim().split(Regex("\\s+")) + if (fields.size < 20) return null + return try { + ProcessInfo(pid, fields[1].toInt(), fields[19].toLong(), fields[0].first()) + } catch (_: NumberFormatException) { + null + } + } + + private fun stop(records: List, includeRoots: Boolean) { + val descendants = records.filter { it.role == Role.SESSION_CHILD || it.role == Role.HELPER_CHILD } + val session = records.filter { it.role == Role.SESSION_ROOT } + val helpers = records.filter { it.role == Role.HELPER_ROOT } + for (record in descendants) send(record, SIGSTOP) + if (includeRoots) { + for (record in session) send(record, SIGSTOP) + for (record in helpers) send(record, SIGSTOP) + } + } + + private fun restore(records: List): Boolean { + val ordered = records.sortedBy { + when (it.role) { + Role.SESSION_ROOT -> 0 + Role.HELPER_ROOT -> 1 + Role.SESSION_CHILD -> 2 + Role.HELPER_CHILD -> 3 + } + } + for (record in ordered) send(record, SIGCONT) + repeat(8) { + Thread.sleep(25L) + if (ordered.all(::isRunningOrGone)) return true + } + return ordered.all(::isRunningOrGone) + } + + private fun send(record: ProcessRecord, signal: Int) { + val current = readProcess(record.pid) ?: return + if (current.started != record.started) return + try { + Process.sendSignal(record.pid, signal) + } catch (_: Throwable) { + } + } + + private fun isStoppedOrGone(record: ProcessRecord): Boolean { + val current = readProcess(record.pid) ?: return true + if (current.started != record.started) return true + return current.state == 'T' || current.state == 't' || current.state == 'Z' || current.state == 'X' + } + + private fun isRunningOrGone(record: ProcessRecord): Boolean { + val current = readProcess(record.pid) ?: return true + if (current.started != record.started) return true + return current.state != 'T' && current.state != 't' + } + + private companion object { + const val SIGSTOP = 19 + const val SIGCONT = 18 + } +} diff --git a/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt index 7eb2c0541..0243fee04 100644 --- a/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt @@ -26,6 +26,8 @@ class ModeSettings( val androidRows: List, val androidSelected: String, val touchMode: String, + val suspendPolicy: String, + /** Steam only. */ val oscMode: String?, val directAudio: Boolean?, val mic: Boolean?, @@ -56,6 +58,7 @@ class ModeSettingsActions( val onImportAndroid: () -> Unit, val onRemoveAndroid: (String) -> Unit, val onTouch: (String) -> Unit, + val onSuspendPolicy: (String) -> Unit, val onOsc: (String) -> Unit, val onDirectAudio: (Boolean) -> Unit, val onMic: (Boolean) -> Unit, @@ -135,6 +138,20 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { note = "Auto shows all controls without a controller. Steam + QAM shows only those buttons.", onPick = a.onOsc, ) } + SettingsGroup("Session") { + ChoiceRow( + host, "suspend", "Background behavior", + "How this session behaves when the app leaves the screen or the display turns off.", + listOf( + SessionPrefs.SUSPEND_AUTO to "Auto", + SessionPrefs.SUSPEND_MANUAL to "Manual", + SessionPrefs.SUSPEND_NEVER to "Never", + ), + s.suspendPolicy, + note = "Auto pauses in the background and resumes when visible. Manual pauses there and waits for Resume. Never keeps the session running.", + onPick = a.onSuspendPolicy, + ) + } if (steam && s.steamChannel != null) SettingsGroup("Client") { ChoiceRow( host, "channel", "Client branch", "The Steam client build the session forces. Applies at the next session start; the client may update itself once.", diff --git a/app/src/main/java/com/steamdeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/steamdeck/launcher/ui/SessionOverlay.kt index ffdc8cc39..d4adfc912 100644 --- a/app/src/main/java/com/steamdeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/steamdeck/launcher/ui/SessionOverlay.kt @@ -110,6 +110,28 @@ fun LoadingOverlay(step: String, percent: Int, elapsed: String, hint: String, en } } +@Composable +fun SessionPausedOverlay(onResume: () -> Unit) { + val interactionSource = remember { MutableInteractionSource() } + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xF20B0D10)) + .clickable(interactionSource = interactionSource, indication = null) {}, + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(32.dp), + ) { + OutlinedButton(onClick = onResume) { + Text("Resume session") + } + } + } +} + +/** Everything the drawer shows and does. */ class DrawerActions( val steam: Boolean, val hudOn: Boolean, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c4cd58ae5..3aab41557 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,6 +9,8 @@ Session Keeps Steam running in the background Steam is running + Session paused - tap Resume when ready + Resume Stop session Frame generation diff --git a/tools/aaudio-sink/build.sh b/tools/aaudio-sink/build.sh index 6f5b37af3..876239019 100755 --- a/tools/aaudio-sink/build.sh +++ b/tools/aaudio-sink/build.sh @@ -12,9 +12,25 @@ API=26 HERE=$(cd "$(dirname "$0")" && pwd) REPO=$(cd "$HERE/../.." && pwd) LIBS="$REPO/app/src/main/jniLibs/arm64-v8a" -TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin" +case "$(uname -s):$(uname -m)" in + Darwin:arm64) NDK_HOST=darwin-arm64 ;; + Darwin:x86_64) NDK_HOST=darwin-x86_64 ;; + Linux:x86_64) NDK_HOST=linux-x86_64 ;; + Linux:aarch64|Linux:arm64) NDK_HOST=linux-aarch64 ;; + *) echo "Unsupported build host: $(uname -s) $(uname -m)" >&2; exit 1 ;; +esac +TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/$NDK_HOST/bin" +if [[ ! -d "$TOOLCHAIN" && "$NDK_HOST" == darwin-arm64 && -d "$NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin" ]]; then + TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin" +fi +if [[ ! -d "$TOOLCHAIN" && "$NDK_HOST" == linux-aarch64 && -d "$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin" ]]; then + TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin" +fi CC="$TOOLCHAIN/aarch64-linux-android${API}-clang" -test -x "$CC" +if [[ ! -x "$CC" ]]; then + echo "Android NDK toolchain not found at $TOOLCHAIN" >&2 + exit 1 +fi test -f "$PA_SRC/src/pulse/version.h.in" INC=$(mktemp -d) mkdir -p "$INC/pulse" diff --git a/tools/build_local.sh b/tools/build_local.sh index 1dda74347..186aea6cd 100755 --- a/tools/build_local.sh +++ b/tools/build_local.sh @@ -86,18 +86,89 @@ if [[ -z "${ndk_version}" || ! -d "${sdk_dir}/ndk/${ndk_version}" ]]; then exit 1 fi +for tool in curl tar zstd; do + if ! command -v "${tool}" >/dev/null 2>&1; then + echo "${tool} is required to build the bundled audio modules." >&2 + exit 1 + fi +done + +staging_dir=$(mktemp -d "${TMPDIR:-/tmp}/steamdeck-build.XXXXXX") +bundle_asset="${repo_root}/app/src/main/assets/pulseaudio.tzst" +bundle_backup="${staging_dir}/pulseaudio.original.tzst" +bundle_replaced=0 +cleanup() { + local exit_code=$? + trap - EXIT + if [[ "${bundle_replaced}" == 1 ]]; then + cp -p "${bundle_backup}" "${bundle_asset}" || exit_code=1 + fi + rm -rf -- "${staging_dir}" || exit_code=1 + exit "${exit_code}" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +pa_source=${STEAMDECK_PA13_SOURCE_DIR:-"${staging_dir}/pulseaudio-13.0"} +if [[ -z "${STEAMDECK_PA13_SOURCE_DIR:-}" ]]; then + curl -fsSL -o "${staging_dir}/pulseaudio-13.0.tar.gz" \ + https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz + mkdir -p "${pa_source}" + tar -xzf "${staging_dir}/pulseaudio-13.0.tar.gz" \ + -C "${pa_source}" --strip-components=1 +fi +if [[ ! -f "${pa_source}/src/pulse/version.h.in" ]]; then + echo "PulseAudio 13.0 source not found at ${pa_source}; set STEAMDECK_PA13_SOURCE_DIR." >&2 + exit 1 +fi + +export NDK="${sdk_dir}/ndk/${ndk_version}" +sink_output="${staging_dir}/sink-out" +"${repo_root}/tools/aaudio-sink/build.sh" "${pa_source}" "${sink_output}" + +cp -p "${bundle_asset}" "${bundle_backup}" +bundle_dir="${staging_dir}/pulseaudio-bundle" +mkdir -p "${bundle_dir}" +zstd -dc "${bundle_asset}" | tar -xf - -C "${bundle_dir}" +if [[ -e "${bundle_dir}/modules/arm64/module-aaudio-sink.so" \ + || -e "${bundle_dir}/modules/arm64/module-directaudio-sink.so" ]]; then + echo "The committed audio bundle already contains a built ARM64 sink." >&2 + exit 1 +fi +install -m755 "${sink_output}/module-aaudio-sink.so" \ + "${bundle_dir}/modules/arm64/module-aaudio-sink.so" +install -m755 "${sink_output}/module-directaudio-sink.so" \ + "${bundle_dir}/modules/arm64/module-directaudio-sink.so" +tar -cf - -C "${bundle_dir}" . | zstd -19 -c > "${staging_dir}/pulseaudio.tzst" +bundle_replaced=1 +mv "${staging_dir}/pulseaudio.tzst" "${bundle_asset}" + cd "${repo_root}" ./gradlew assembleRelease -PndkVersion="${ndk_version}" +cp -p "${bundle_backup}" "${bundle_asset}" +bundle_replaced=0 apk="${repo_root}/app/build/outputs/apk/release/app-release.apk" +audio_check="${staging_dir}/audio-check" +mkdir -p "${audio_check}" +unzip -p "${apk}" assets/pulseaudio.tzst | zstd -dc | tar -xf - -C "${audio_check}" +for audio_file in \ + pactl \ + modules/arm64/module-aaudio-sink.so \ + modules/arm64/module-directaudio-sink.so; do + if [[ ! -f "${audio_check}/${audio_file}" ]]; then + echo "APK audio bundle is missing ${audio_file}." >&2 + exit 1 + fi +done + build_tools=$(find "${sdk_dir}/build-tools" -mindepth 1 -maxdepth 1 -type d -print | sort -V | tail -1) if [[ ! -x "${build_tools}/zipalign" || ! -x "${build_tools}/apksigner" ]]; then echo "Android build-tools with zipalign/apksigner are required under ${sdk_dir}/build-tools." >&2 exit 1 fi -staging_dir=$(mktemp -d "${TMPDIR:-/tmp}/steamdeck-apk.XXXXXX") -trap 'rm -rf -- "${staging_dir}"' EXIT "${build_tools}/zipalign" -p -f 4 "${apk}" "${staging_dir}/app-release.aligned.apk" "${build_tools}/apksigner" sign \ --ks keystore/testkey.p12 --ks-type PKCS12 --ks-pass pass:android \