mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 03:21:08 +00:00
Merge pull request #7 from The412Banner/feature/session-suspend-audio-recovery
This commit is contained in:
16 files changed
+691
-39
No files matched your search
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
@@ -153,6 +153,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var hdrOn by mutableStateOf(false)
|
||||
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 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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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:<this>. */
|
||||
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;
|
||||
|
||||
@@ -23,4 +23,8 @@ public abstract class SessionPart {
|
||||
public abstract void start();
|
||||
|
||||
public abstract void stop();
|
||||
|
||||
public int suspendPid() {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
@@ -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 ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<SessionPart>()
|
||||
private val components = java.util.concurrent.CopyOnWriteArrayList<SessionPart>()
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> = emptyList()
|
||||
|
||||
@@ -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<Int>,
|
||||
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<ProcessRecord>()
|
||||
|
||||
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<Pair<Int, Long>, 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<Pair<Int, Long>, ProcessRecord>,
|
||||
root: Int,
|
||||
helpers: List<Int>,
|
||||
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<ProcessInfo>, byPid: Map<Int, ProcessInfo>): List<ProcessInfo> {
|
||||
if (root !in byPid) return emptyList()
|
||||
val children = HashMap<Int, MutableList<ProcessInfo>>()
|
||||
for (process in processes) children.getOrPut(process.parent) { ArrayList() }.add(process)
|
||||
val excluded = Process.myPid()
|
||||
val found = ArrayList<ProcessInfo>()
|
||||
val seen = HashSet<Int>()
|
||||
val queue = ArrayDeque<Int>().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<ProcessInfo> =
|
||||
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<ProcessRecord>, 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<ProcessRecord>): 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
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ class ModeSettings(
|
||||
val androidRows: List<DriverRow>,
|
||||
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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<string name="session_channel">Session</string>
|
||||
<string name="session_channel_description">Keeps Steam running in the background</string>
|
||||
<string name="session_notification">Steam is running</string>
|
||||
<string name="session_notification_paused">Session paused - tap Resume when ready</string>
|
||||
<string name="resume_session">Resume</string>
|
||||
<string name="stop_session">Stop session</string>
|
||||
<string name="frame_gen_title">Frame generation</string>
|
||||
</resources>
|
||||
@@ -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"
|
||||
|
||||
+73
-2
@@ -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 \
|
||||
|
||||
Reference in new issue
Block a user