Files
DroidDeck/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt
T

1014 lines
50 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.steamdeck.launcher
import android.hardware.input.InputManager
import android.hardware.display.DisplayManager
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.Display
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.View
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import com.steamdeck.launcher.core.FileUtils
import com.steamdeck.launcher.gpu.FrameGen
import com.steamdeck.launcher.gpu.LsfgNative
import com.steamdeck.launcher.gpu.TurnipDriver
import com.steamdeck.launcher.input.EvdevKeys
import com.steamdeck.launcher.input.KeyboardHost
import com.steamdeck.launcher.input.OnScreenControls
import com.steamdeck.launcher.input.PadBridge
import com.steamdeck.launcher.input.PointerGestures
import com.steamdeck.launcher.input.SecondScreenDisplay
import com.steamdeck.launcher.input.SecondScreenMode
import com.steamdeck.launcher.input.TouchpadGestures
import com.steamdeck.launcher.runtime.LinuxRuntime
import com.steamdeck.launcher.session.LoadingState
import com.steamdeck.launcher.session.PerfHints
import com.steamdeck.launcher.session.PerfHud
import com.steamdeck.launcher.session.PerfMode
import com.steamdeck.launcher.session.SessionPrefs
import com.steamdeck.launcher.session.SessionPaths
import com.steamdeck.launcher.wayland.HdrSupport
import com.steamdeck.launcher.session.SessionService
import com.steamdeck.launcher.session.SessionState
import com.steamdeck.launcher.ui.CursorOverlay
import com.steamdeck.launcher.ui.DrawerActions
import com.steamdeck.launcher.ui.HudText
import com.steamdeck.launcher.ui.LoadingOverlay
import com.steamdeck.launcher.ui.SessionDrawer
import com.steamdeck.launcher.ui.SessionPausedOverlay
import com.steamdeck.launcher.ui.SecondScreenPresentation
import com.steamdeck.launcher.ui.SteamDeckTheme
import com.steamdeck.launcher.wayland.CompositorHost
import com.steamdeck.launcher.wayland.WaylandCompositor
import java.io.File
/**
* The session's screen: our Wayland compositor presenting onto this activity's Surface, and the
* input that reaches it. The session itself - gamescope, the Steam client, audio - belongs to
* [SessionService] and keeps running when this activity does not exist, which is what lets the
* user leave Big Picture for another app and come back to it still signed in and still
* downloading.
*
* Three layers: the SurfaceView the compositor draws into, the on-screen pad (a canvas View,
* since it is input rather than a menu), and one Compose layer on top for everything else -
* the HUD line, the loading overlay, the drawer and its dialogs.
*/
class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private lateinit var surfaceView: SurfaceView
private lateinit var loading: LoadingState
private lateinit var hud: PerfHud
private var padBridge: PadBridge? = null
private var onScreenControls: OnScreenControls? = null
private var keyboard: KeyboardHost? = null
private var watching = true
private lateinit var gestures: PointerGestures
private lateinit var touchpad: TouchpadGestures
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
private var cursorPos by mutableStateOf(androidx.compose.ui.geometry.Offset(-100f, -100f))
private var cursorVisible by mutableStateOf(false)
private val cursorHide = Runnable { cursorVisible = false }
private val uiHandler = Handler(Looper.getMainLooper())
private var pendingBackAction: Runnable? = null
// Compose reads these; the activity writes them.
private var drawerOpen by mutableStateOf(false)
private var backActionsInverted by mutableStateOf(false)
private var hudOn by mutableStateOf(true)
private var frameGenLabel by mutableStateOf("Off")
private var frameGenEngine by mutableStateOf(FrameGen.ENGINE_OFF)
private var frameGenMultiplier by mutableStateOf(2)
private var fexPreset by mutableStateOf("")
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
private var secondScreenMode by mutableStateOf(SecondScreenMode.NONE)
private var secondScreenDisplays by mutableStateOf<List<SecondScreenDisplay>>(emptyList())
private var selectedSecondScreenDisplay by mutableStateOf(-1)
private lateinit var displayManager: DisplayManager
private var secondScreenPresentation: SecondScreenPresentation? = null
private val displayListener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) = refreshSecondScreenDisplays()
override fun onDisplayRemoved(displayId: Int) = refreshSecondScreenDisplays()
override fun onDisplayChanged(displayId: Int) = refreshSecondScreenDisplays()
}
private var isHomeApp by mutableStateOf(false)
private var androidApps by mutableStateOf<List<HomeApp.LaunchableApp>>(emptyList())
/**
* Shows the on-screen pad when nothing is plugged in and takes it away the moment something
* is - a user with a controller in their hands should not be looking at buttons they cannot
* press, and a user without one must not be left with no way to answer Big Picture.
*/
private val deviceListener = object : InputManager.InputDeviceListener {
override fun onInputDeviceAdded(deviceId: Int) = updateOnScreenControls()
override fun onInputDeviceRemoved(deviceId: Int) = updateOnScreenControls()
override fun onInputDeviceChanged(deviceId: Int) = updateOnScreenControls()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// Game-tier power policy for the whole session: a sustained clock floor, the panel's
// fastest mode, and the OS told it is in gameplay. Logged so a slow device says why.
Log.i(TAG, "perf: " + PerfMode.apply(this))
goFullscreen()
// The device's volume keys change the stream the session plays on (the relay and
// PulseAudio are media playback); they are never forwarded to the guest.
volumeControlStream = android.media.AudioManager.STREAM_MUSIC
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
refreshSecondScreenDisplays()
// No runtime is not a reason to leave: the loading screen installs it (installThenStart,
// below) and the session starts when it is in.
if (!LinuxRuntime.isInstalled(this)) Log.i(TAG, "the Linux runtime is not installed; the loading screen installs it")
val root = FrameLayout(this)
surfaceView = SurfaceView(this)
surfaceView.holder.addCallback(this)
root.addView(surfaceView)
val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input"))
padBridge = bridge
onScreenControls = OnScreenControls(this, bridge).also { root.addView(it) }
keyboard = KeyboardHost(this).also { root.addView(it) }
gestures = PointerGestures(PointerGestures.slop(this), pointerListener)
touchpad = TouchpadGestures(PointerGestures.slop(this), pointerListener)
// One arrow, ours: Android draws a system pointer for a mouse over any window, and the
// session already draws the pointer it is sent.
val noCursor = android.view.PointerIcon.getSystemIcon(this, android.view.PointerIcon.TYPE_NULL)
root.pointerIcon = noCursor
surfaceView.pointerIcon = noCursor
loading = LoadingState(this)
if (!SessionState.running) {
val needRuntime = com.steamdeck.launcher.runtime.LinuxRuntimeInstaller.installedVersion(this) == null
val needDesktop = intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_DESKTOP &&
!com.steamdeck.launcher.runtime.DesktopCatalog.desktopInstalled(this)
if (needRuntime || needDesktop) installThenStart(needRuntime, needDesktop)
}
hud = PerfHud(this)
hud.onPresentingWindowChanged = {
if (FrameGen.engine(this) != FrameGen.ENGINE_OFF) CompositorHost.rearmFrameGen { applyFrameGen() }
}
// A session that has already drawn is past its milestones; do not cover its picture.
if (SessionState.running && SessionState.firstFrameSeen) {
loading.visible = false
hud.start()
}
readPrefs()
// One Compose layer for every menu and overlay. Touches nothing in it consumes fall
// through to the pad and the game underneath.
root.addView(ComposeView(this).apply {
setContent {
SteamDeckTheme {
CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density)
if (hud.text.isNotEmpty()) HudText(hud.text)
if (loading.visible) LoadingOverlay(loading.step, loading.percent, loading.elapsed, loading.hint, loading.ended)
SessionDrawer(drawerOpen, DrawerActions(
steam = SessionState.mode == SessionService.MODE_STEAM,
isHomeApp = isHomeApp,
androidApps = androidApps,
hudOn = hudOn,
frameGenEngine = frameGenEngine, frameGenMultiplier = frameGenMultiplier,
lsfgReady = LsfgNative.isInstalled(this@SessionActivity),
oscMode = oscMode, touchMode = touchMode,
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
shapeMode = shapeMode, fexPreset = fexPreset,
secondScreenMode = secondScreenMode,
secondScreenDisplays = secondScreenDisplays,
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
onFrameGenPick = { engine, multiplier ->
FrameGen.set(this@SessionActivity, engine, multiplier)
readPrefs()
applyFrameGen()
},
onKeyboard = { drawerOpen = false; keyboard?.toggle() },
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({ triggerSteamQam() }) else null,
backActionsInverted = backActionsInverted,
onBackActionsInverted = { inverted ->
SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted)
backActionsInverted = inverted
},
onOsc = { v -> SessionPrefs.setOscMode(this@SessionActivity, v); readPrefs(); updateOnScreenControls() },
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
onShape = { v -> SessionPrefs.setShapeMode(this@SessionActivity, v); readPrefs() },
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
onSecondScreenMode = ::selectSecondScreenMode,
onSecondScreenDisplay = ::selectSecondScreenDisplay,
onLaunchAndroidApp = { app ->
drawerOpen = false
try {
HomeApp.launch(this@SessionActivity, app)
} catch (_: Exception) {
Toast.makeText(this@SessionActivity, "Could not open ${app.label}", Toast.LENGTH_SHORT).show()
}
},
onBackground = { drawerOpen = false; moveTaskToBack(true) },
onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() },
onClose = { drawerOpen = false },
))
if (SessionState.suspended) SessionPausedOverlay {
SessionService.resume(this@SessionActivity)
}
}
}
})
setContentView(root)
handleHomeGuideIntent(intent)
updateOnScreenControls()
WaylandCompositor.setFirstFrameListener {
SessionState.firstFrameSeen = true
runOnUiThread {
loading.visible = false
hud.start()
}
}
SessionState.endListener = endListener
// A single Back opens the session menu; two quick presses/swipes send the Steam QAM chord.
// The single action waits out the double-press window so the two actions stay distinct.
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
routeBackAction()
}
})
watchSession()
}
private fun refreshHomeApp() {
isHomeApp = HomeApp.isDefault(this)
androidApps = if (isHomeApp) HomeApp.launchableApps(this) else emptyList()
}
private fun sendSteamGuide() {
drawerOpen = false
padBridge?.applyTouch { state -> state.press(com.steamdeck.launcher.input.PadState.GUIDE, true) }
uiHandler.postDelayed({
padBridge?.applyTouch { state -> state.press(com.steamdeck.launcher.input.PadState.GUIDE, false) }
}, 90)
}
private fun triggerSteamQam() {
drawerOpen = false
padBridge?.triggerQam()
}
private fun routeBackAction() {
pendingBackAction?.let { pending ->
uiHandler.removeCallbacks(pending)
pendingBackAction = null
performBackAction(double = true)
return
}
val pending = Runnable {
pendingBackAction = null
performBackAction(double = false)
}
pendingBackAction = pending
uiHandler.postDelayed(pending, BACK_DOUBLE_PRESS_TIMEOUT_MS)
}
private fun performBackAction(double: Boolean) {
val isSteam = SessionState.mode == SessionService.MODE_STEAM
val opensQam = isSteam && (double xor backActionsInverted)
if (opensQam) triggerSteamQam() else drawerOpen = !drawerOpen
}
private fun handleHomeGuideIntent(incoming: Intent?) {
if (incoming?.action != SessionService.ACTION_HOME_GUIDE) return
incoming.action = null
setIntent(incoming)
if (SessionState.running && SessionState.mode == SessionService.MODE_STEAM) sendSteamGuide()
}
private fun readPrefs() {
hudOn = SessionPrefs.hudEnabled(this)
touchMode = SessionPrefs.touchMode(this)
frameGenLabel = FrameGen.label(this)
frameGenEngine = FrameGen.engine(this)
frameGenMultiplier = FrameGen.multiplier(this)
fexPreset = SessionPrefs.fexPreset(this)
oscMode = SessionPrefs.oscMode(this)
shapeMode = SessionPrefs.shapeMode(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
}
// ── Compositor ──────────────────────────────────────────────────────────────────────────
/** True while the loading screen is installing the Linux runtime or the desktop; the session waits for it. */
@Volatile private var installingRuntime = false
/**
* First Play (or Desktop) on a fresh install: the runtime, and for the desktop its package,
* are downloaded and unpacked here, on the loading screen's own line and bar, and the session
* starts when they are in. Nothing else changes.
*/
private fun installThenStart(runtime: Boolean, desktop: Boolean) {
installingRuntime = true
loading.step = if (runtime) "downloading the Linux runtime" else "downloading the desktop"
loading.percent = -1
Thread({
val problem = (if (runtime) installRuntime() else null) ?: (if (desktop) installDesktop() else null)
uiHandler.post {
installingRuntime = false
if (problem != null) { loading.showEnded(problem); return@post }
loading.percent = -1
loading.step = "Starting the session…"
// The surface may have come and gone while the download ran; start on the live one.
if (surfaceView.holder.surface?.isValid == true) surfaceCreated(surfaceView.holder)
}
}, "runtime-install").start()
}
/** Reports one package's download on the loading screen: "<what> · 332 of 791 MB", checking, unpacking. */
private fun progressFor(what: String, mb: Long) = com.steamdeck.launcher.runtime.LinuxRuntimeInstaller.ProgressListener { stage, p ->
uiHandler.post {
loading.percent = p
loading.step = when {
stage.startsWith("Downloading") -> if (p >= 0 && mb > 0) "downloading $what · ${p * mb / 100} of $mb MB" else "downloading $what"
stage.startsWith("Verifying") -> "checking $what"
else -> "unpacking $what"
}
}
}
/** Null when the runtime is in, else the loading screen's closing line. */
private fun installRuntime(): String? {
val release = com.steamdeck.launcher.runtime.LinuxRuntimeInstaller.fetchRelease()
?: return "Could not reach the runtime catalog. Check the connection and press Play again."
val ok = com.steamdeck.launcher.runtime.LinuxRuntimeInstaller.install(this, release,
progressFor("the Linux runtime", release.size / 1_000_000))
return if (ok) null else "The Linux runtime did not install. Check the connection and press Play again."
}
/** Null when the desktop package is in, else the loading screen's closing line. */
private fun installDesktop(): String? {
uiHandler.post { loading.percent = -1; loading.step = "downloading the desktop" }
val entry = com.steamdeck.launcher.runtime.DesktopCatalog.fetch()?.firstOrNull { it.id == "desktop" }
?: return "Could not reach the desktop catalog. Check the connection and press Desktop again."
val problem = com.steamdeck.launcher.runtime.DesktopCatalog.install(this, entry,
progressFor("the desktop", entry.size / 1_000_000))
return problem?.let { "The desktop did not install ($it). Check the connection and press Desktop again." }
}
override fun surfaceCreated(holder: SurfaceHolder) {
if (installingRuntime) return
val runtimeDir = File(filesDir, ".wayland-rt").apply { mkdirs() }
// The compositor hands this keymap to wl_keyboard clients, which is how the guest reads
// the evdev codes we inject.
FileUtils.copyAsset(this, "wayland/keymap.xkb", File(runtimeDir, "keymap.xkb"))
// Turnip, not the system Adreno driver: importing the dma-bufs gamescope commits needs
// VK_EXT_image_drm_format_modifier, which the system driver does not implement.
val turnip = TurnipDriver(this)
val driverId = if (CompositorHost.isStarted) null else turnip.install()
// The output size belongs to the session, not to the Surface: gamescope's display is
// sized once when the session starts and cannot change. A foldable recreates the Surface
// on the other panel, and recomputing the size there told the compositor a 21:9 buffer
// was 16:9 - the picture came back squashed sideways. While a session runs, keep its size.
val size = if (SessionState.running) SessionState.outputSize else outputSize()
SessionState.outputSize = size
onScreenControls?.setPicture(drawnRect())
if (!SessionState.running) SessionState.refreshHz = refreshHz()
// Letterbox, never stretch or crop: the output can be a different shape from the panel,
// and a game's picture must keep its proportions with bars, not lose its edges.
WaylandCompositor.nativeSetScaleMode(SCALE_FIT, ALIGN_CENTER)
// The session's folder, claimed here because the compositor starts before the service and
// opens its log once. The compositor reads the path from its environment; setting it after
// it has started changes nothing, which is why the service copies the file in at teardown.
if (!SessionState.running) {
val waylandLog = File(SessionPaths.beginOrCurrent(this), "wayland.log")
WaylandCompositor.setSessionLogFile(waylandLog)
try {
android.system.Os.setenv("BL_WAYLAND_LOG", waylandLog.path, true)
} catch (e: Exception) {
Log.w(TAG, "could not point the compositor's log at $waylandLog", e)
}
}
// HDR10: the gate is decided once, when the compositor starts (it lives for the whole app
// process), from the panel's own word and the mode's setting. Zero-copy presentation is
// what puts an HDR frame on a display layer tagged BT2020_PQ, so it is turned on with it.
if (!CompositorHost.isStarted) {
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
val probe = HdrSupport.probe(this)
WaylandCompositor.nativeSetHdrDisplay(
probe.displayId, probe.name, probe.formats, probe.hdr10,
probe.maxLuminance, probe.maxAverageLuminance, probe.minLuminance,
probe.ratioAvailable, probe.ratio, Build.VERSION.SDK_INT,
)
val wanted = SessionPrefs.hdr(this, mode)
val on = wanted && probe.reason == null
SessionState.hdr = on
if (on) {
try { android.system.Os.setenv("BANNER_WAYLAND_HDR", "1", true) } catch (e: Exception) { Log.w(TAG, "BANNER_WAYLAND_HDR", e) }
WaylandCompositor.nativeSetZeroCopy(true)
}
WaylandCompositor.nativeSetHdrRequest(
if (on) WaylandCompositor.HDR_MODE_ON else WaylandCompositor.HDR_MODE_OFF,
"$mode session settings" + (if (wanted && !on) " (refused: ${probe.reason})" else ""),
on, on,
)
Log.i(TAG, "hdr: " + (if (on) "on" else if (wanted) "wanted but ${probe.reason}" else "off") + " · display ${probe.formats.ifEmpty { "SDR" }}")
}
CompositorHost.startOrAttach(
holder.surface, runtimeDir.path,
driverId?.let { turnip.driverPath(it) }, driverId?.let { turnip.libraryName(it) },
applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(),
)
// ADPF: the compositor thread's frame intervals go to the power HAL against the panel's
// period, so a long frame raises CPU clocks now rather than after the load averages up.
PerfHints.arm(this, refreshHz())
// The service owns everything below the compositor. It is started whenever no session is
// running - NOT only when the compositor was just started: the compositor lives for the
// whole process, so the second Play after a session ended used to re-attach the Surface,
// start nothing, and leave the loading panel counting up over a dead session.
if (!SessionState.running) {
CompositorHost.newSession()
SessionService.start(
this, intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM,
intent.getStringExtra(SessionService.EXTRA_PROGRAM),
intent.getStringExtra(SessionService.EXTRA_STEAM_UI),
intent.getStringExtra(SessionService.EXTRA_STEAM_URL),
intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS),
)
}
applyFrameGen()
}
private var surfaceW = 0
private var surfaceH = 0
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
val resized = surfaceW != 0 && (width != surfaceW || height != surfaceH)
surfaceW = width
surfaceH = height
// The on-screen controls follow the picture: into the bars beside or under it when there are any.
onScreenControls?.setPicture(drawnRect())
if (resized) {
Log.i(TAG, "surface resized to ${width}x$height - rebinding the compositor")
CompositorHost.resize(holder.surface) { applyFrameGen() }
}
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
surfaceW = 0
surfaceH = 0
CompositorHost.detach(holder.surface)
}
/**
* The size the session renders at. gamescope is told this and scales its output onto whatever
* the panel is, so a 1440p phone can run the client at 1080p without the client knowing.
*/
private fun outputSize(): Pair<Int, Int> {
// The panel, not the window: resources.displayMetrics is what is left after the system
// bars and the cutout are taken out.
val bounds = if (Build.VERSION.SDK_INT >= 30) {
windowManager.maximumWindowMetrics.bounds
} else {
val metrics = android.util.DisplayMetrics()
@Suppress("DEPRECATION")
windowManager.defaultDisplay.getRealMetrics(metrics)
android.graphics.Rect(0, 0, metrics.widthPixels, metrics.heightPixels)
}
val panelW = maxOf(bounds.width(), bounds.height()).toFloat()
val panelH = minOf(bounds.width(), bounds.height()).toFloat()
// Never narrower than 16:9. A foldable's inner panel is nearly square, and a game handed a
// square display draws for the frame it was made for and cuts the sides off itself.
// Wider than 16:9 is fine - games and the client cope with a phone's 20:9 - so the
// panel's aspect is kept above that, unless the user pinned 16:9 for a foldable, and the
// compositor letterboxes onto a squarer panel.
// "Exactly this panel" drops that floor, for a 4:3 or 3:2 handheld whose games should
// fill it.
val aspect = when (SessionPrefs.shapeMode(this)) {
SessionPrefs.SHAPE_WIDE -> 16f / 9f
SessionPrefs.SHAPE_EXACT -> panelW / panelH
else -> maxOf(panelW / panelH, 16f / 9f)
}
// 720 tall at most by default, client and desktop alike: the client's CEF is the heaviest
// thing in the session, and pixels above that cost frames for nothing anyone can see on a
// handheld panel. The mode's settings (the cog beside Play / Desktop) can change
// the cap or lift it to the panel.
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
// A custom resolution is taken as given; the compositor fits it to the panel.
SessionPrefs.customResolution(this, mode)?.let { return it }
val cap = SessionPrefs.resolutionCap(this, mode)
val height = (if (cap <= 0) panelH else minOf(panelH, cap.toFloat())).toInt()
val width = (height * aspect).toInt()
// Odd sizes upset the scaler; both dimensions even is what every mode here would be.
return Pair(width and 1.inv(), height and 1.inv())
}
private fun refreshHz(): Float {
val display = if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay
val hz = display?.refreshRate ?: 60f
return if (hz > 1f) hz else 60f
}
/**
* The saved frame-generation setting, pushed to the compositor. Off the main thread: LSFG's
* first use translates the shader chain out of Lossless.dll, which takes seconds.
*/
private fun applyFrameGen() {
val hz = refreshHz()
Thread({
val problem = FrameGen.apply(this, hz)
if (problem != null) runOnUiThread { Toast.makeText(this, problem, Toast.LENGTH_LONG).show() }
}, "frame-gen").start()
}
// ── Session state ───────────────────────────────────────────────────────────────────────
/** Keeps the loading overlay current from the session log, and its clock moving. */
private fun watchSession() {
val handler = Handler(Looper.getMainLooper())
var ticks = 0
val poll = object : Runnable {
override fun run() {
if (!watching) return
if (loading.visible && !loading.ended) {
if (!installingRuntime) loading.update(this@SessionActivity, SessionState.logFile)
if (ticks++ % 2 == 0) loading.tick()
}
handler.postDelayed(this, 500)
}
}
handler.post(poll)
}
private fun onSessionEnded(status: Int) {
closeSecondScreen(reset = true)
if (status == 0) {
runOnUiThread {
if (isFinishing || isDestroyed) return@runOnUiThread
// A session the guest asked for (the desktop's Steam launchers) takes this one's
// place: the compositor stays, a new activity attaches and starts the service.
SessionState.relaunch?.let { next ->
SessionState.relaunch = null
startActivity(next)
}
finish()
}
return
}
// What the log says about why, read off the main thread (notifyEnded arrives on it).
Thread({
val hint = sessionEndHint(SessionState.logFile)
runOnUiThread {
if (isFinishing || isDestroyed) return@runOnUiThread
showEnded(status, hint)
}
}, "session-end-hint").start()
}
private fun showEnded(status: Int, hint: String?) {
run {
if (status != 0) {
loading.showEnded(
"The session ended ($status)\n${SessionState.logFile?.path ?: "-"}" +
(if (hint != null) "\n\n$hint" else "")
)
// A moment on screen, so a failure is readable rather than a flash of black;
// longer when there is advice to read.
Handler(Looper.getMainLooper()).postDelayed({ finish() }, if (hint != null) 9000 else 4000)
} else {
finish()
}
}
}
/**
* One line of advice for a failure the log identifies, or null. The one this recognises: the
* ENOSYS storm - dozens of `socket(): Function not implemented` / `shared memfd open() failed`
* lines - which is proot's seccomp acceleration failing an x86 helper (Steam's xalia under
* FEX) on some devices (a Fold 5, twice). The switch that answers it is in Performance, and a
* user who never opens the log would not know.
*/
private fun sessionEndHint(log: File?): String? {
if (log == null || !log.isFile) return null
return try {
var enosys = 0
// The tail is where a dying session says why; 512 KB covers the storm without reading a 1 GB log.
val size = log.length()
java.io.RandomAccessFile(log, "r").use { f ->
val start = maxOf(0L, size - 512L * 1024)
f.seek(start)
val bytes = ByteArray((size - start).toInt())
f.readFully(bytes)
String(bytes, Charsets.ISO_8859_1).lineSequence().forEach { line ->
if (line.contains("Function not implemented")) enosys++
}
}
if (enosys >= 8) {
"The log shows $enosys \"Function not implemented\" errors: proot's seccomp acceleration is failing a helper on this device. " +
"Try Performance \u2192 \"Run proot without seccomp\" (or \"Skip Steam's xalia helper\") and start again."
} else null
} catch (e: Exception) {
null
}
}
// ── Input ───────────────────────────────────────────────────────────────────────────────
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
// The device's own volume keys belong to Android: forwarded to the guest as keys they
// changed nothing anyone could hear (a Pocket FIT report, on the desktop).
when (event.keyCode) {
KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_MUTE ->
return super.dispatchKeyEvent(event)
}
if (event.keyCode != KeyEvent.KEYCODE_BACK && padBridge?.onKeyEvent(event) == true) return true
// A hardware keyboard, forwarded to the compositor's wl_keyboard. Back is left to the
// activity, which opens the drawer.
val fromPad = event.device != null && PadBridge.isFromController(event.device)
if (CompositorHost.isStarted && event.keyCode != KeyEvent.KEYCODE_BACK && !fromPad) {
val down = event.action == KeyEvent.ACTION_DOWN
if (down || event.action == KeyEvent.ACTION_UP) {
var evdev = EvdevKeys.fromKeyCode(event.keyCode)
// What the key stands for, and the key it would sit on without Shift - non-zero
// only for a character Shift puts there: every capital and the symbol row.
val ch = event.unicodeChar
val plain = if (ch > 0) EvdevKeys.unshiftedChar(ch) else 0
// A soft keyboard's symbol keys are in neither the table nor a scan code, so they
// reached the session as nothing at all - a sign-in took an address without its @.
// Work back from the character instead: which key carries it.
if (evdev <= 0 && ch > 0) {
val code = EvdevKeys.fromKeyCode(EvdevKeys.keycodeForChar(if (plain != 0) plain else ch))
if (code > 0) evdev = code
}
// The modifier has to be made here. A soft keyboard reports Shift in the event's
// meta state and sends no Shift key of its own, so a capital arrives as a key this
// side already knows and came out lowercase. A hardware keyboard sends its own
// Shift, and a second one would release the modifier while the key is still held.
val shiftEvdev = if (evdev > 0 && plain != 0 && event.deviceId <= 0) 42 else 0
if (evdev <= 0 && event.scanCode > 0) evdev = event.scanCode
if (evdev > 0) {
if (down) {
if (shiftEvdev != 0) WaylandCompositor.nativeSendKey(shiftEvdev, 1)
WaylandCompositor.nativeSendKey(evdev, 1)
} else {
WaylandCompositor.nativeSendKey(evdev, 0)
if (shiftEvdev != 0) WaylandCompositor.nativeSendKey(shiftEvdev, 0)
}
return true
}
}
}
return super.dispatchKeyEvent(event)
}
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
if (padBridge?.onMotionEvent(event) == true) return true
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE) && !drawerOpen && onMouse(event)) return true
return super.dispatchGenericMotionEvent(event)
}
/**
* The pointer. Touch goes through [PointerGestures] (tap, hold, drag, two-finger scroll); a
* mouse arrives with real buttons and a wheel and is forwarded as it is. Every position is
* mapped through the letterboxed rectangle into the compositor's fixed 1920x1080 pointer
* space, and the arrow is drawn where the app last sent the pointer.
*/
private val pointerListener = object : PointerGestures.Listener {
override fun onMove(x: Float, y: Float) = movePointer(x, y)
override fun onButton(button: Int, pressed: Boolean, x: Float, y: Float) {
movePointer(x, y)
WaylandCompositor.nativeSendSceneInput(3, button, if (pressed) 1 else 0)
}
override fun onWheel(steps: Int) { WaylandCompositor.nativeSendSceneInput(4, steps, 0) }
override fun onLongPress() {
@Suppress("DEPRECATION")
(getSystemService(VIBRATOR_SERVICE) as? android.os.Vibrator)?.vibrate(15)
}
}
private fun movePointer(x: Float, y: Float) {
val width = surfaceView.width.takeIf { it > 0 } ?: return
val height = surfaceView.height.takeIf { it > 0 } ?: return
val out = SessionState.outputSize
val scale = minOf(width / out.first.toFloat(), height / out.second.toFloat())
val drawnW = out.first * scale
val drawnH = out.second * scale
val left = (width - drawnW) / 2f
val top = (height - drawnH) / 2f
val px = ((x - left) / drawnW * 1920f).toInt().coerceIn(0, 1919)
val py = ((y - top) / drawnH * 1080f).toInt().coerceIn(0, 1079)
WaylandCompositor.nativeSendPointer(1, px, py)
showCursor(x.coerceIn(left, left + drawnW), y.coerceIn(top, top + drawnH))
}
/** The arrow stays on a desktop; in a Steam session it shows for a moment after each move. */
private fun showCursor(x: Float, y: Float) {
cursorPos = androidx.compose.ui.geometry.Offset(x, y)
cursorVisible = true
uiHandler.removeCallbacks(cursorHide)
if (SessionState.mode == SessionService.MODE_STEAM && SessionState.steamUi != "desktop") uiHandler.postDelayed(cursorHide, 2500)
}
private fun refreshSecondScreenDisplays() {
if (!::displayManager.isInitialized) return
val primaryId = display?.displayId ?: windowManager.defaultDisplay.displayId
val candidates = displayManager.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION)
.asSequence()
.filter { it.isValid && it.displayId != primaryId && (it.flags and Display.FLAG_PRESENTATION) != 0 }
.map { target ->
val mode = target.mode
SecondScreenDisplay(target.displayId, "${target.name} · ${mode.physicalWidth}×${mode.physicalHeight}")
}
.toList()
val oldIds = secondScreenDisplays.map { it.id }.toSet()
secondScreenDisplays = candidates
val newIds = candidates.map { it.id }.toSet()
if (secondScreenMode != SecondScreenMode.NONE && oldIds.isNotEmpty() && selectedSecondScreenDisplay !in newIds) {
closeSecondScreen(reset = true)
return
}
if (selectedSecondScreenDisplay !in newIds) selectedSecondScreenDisplay = candidates.firstOrNull()?.id ?: -1
if (secondScreenMode != SecondScreenMode.NONE && candidates.isEmpty()) closeSecondScreen(reset = true)
}
private fun selectSecondScreenDisplay(displayId: Int) {
if (secondScreenDisplays.none { it.id == displayId }) return
selectedSecondScreenDisplay = displayId
if (secondScreenMode != SecondScreenMode.NONE) showSecondScreen(secondScreenMode)
}
private fun selectSecondScreenMode(mode: SecondScreenMode) {
if (mode == SecondScreenMode.NONE) {
closeSecondScreen(reset = true)
return
}
if (secondScreenDisplays.isEmpty()) {
secondScreenMode = SecondScreenMode.NONE
return
}
if (selectedSecondScreenDisplay !in secondScreenDisplays.map { it.id }) {
selectedSecondScreenDisplay = secondScreenDisplays.first().id
}
showSecondScreen(mode)
}
private fun showSecondScreen(mode: SecondScreenMode) {
val target = displayManager.getDisplay(selectedSecondScreenDisplay)
if (target == null || !target.isValid || (target.flags and Display.FLAG_PRESENTATION) == 0) {
closeSecondScreen(reset = true)
refreshSecondScreenDisplays()
return
}
var presentation = secondScreenPresentation
if (presentation?.display?.displayId != target.displayId) {
presentation?.closeControls()
presentation = SecondScreenPresentation(
this, target,
sendPointer = ::moveSecondScreenPointer,
sendButton = { button, pressed ->
WaylandCompositor.nativeSendSceneInput(3, button, if (pressed) 1 else 0)
},
sendWheel = { steps -> WaylandCompositor.nativeSendSceneInput(4, steps, 0) },
onSteamMenu = {
if (SessionState.mode == SessionService.MODE_STEAM) {
padBridge?.applyTouch { st -> st.press(com.steamdeck.launcher.input.PadState.GUIDE, true) }
Handler(Looper.getMainLooper()).postDelayed({
padBridge?.applyTouch { st -> st.press(com.steamdeck.launcher.input.PadState.GUIDE, false) }
}, 90)
}
},
onQam = {
if (SessionState.mode == SessionService.MODE_STEAM) padBridge?.triggerQam()
},
)
secondScreenPresentation = presentation
}
try {
if (!presentation.isShowing) presentation.show()
presentation.showMode(mode)
secondScreenMode = mode
} catch (e: Exception) {
Log.w(TAG, "could not show second-screen controls on ${target.name}", e)
closeSecondScreen(reset = true)
}
}
private fun moveSecondScreenPointer(x: Float, y: Float, width: Float, height: Float) {
if (width <= 0f || height <= 0f) return
val nx = (x / width).coerceIn(0f, 1f)
val ny = (y / height).coerceIn(0f, 1f)
WaylandCompositor.nativeSendPointer(1, (nx * 1919f).toInt(), (ny * 1079f).toInt())
drawnRect()?.let { rect ->
showCursor(rect.left + nx * rect.width(), rect.top + ny * rect.height())
}
}
private fun closeSecondScreen(reset: Boolean) {
secondScreenPresentation?.closeControls()
secondScreenPresentation = null
if (reset) secondScreenMode = SecondScreenMode.NONE
}
/** Touchpad on the desktop, direct in Steam, unless the drawer says otherwise. */
private fun usingTouchpad(): Boolean = when (SessionPrefs.touchMode(this)) {
SessionPrefs.TOUCH_PAD -> true
SessionPrefs.TOUCH_DIRECT -> false
else -> SessionState.mode != SessionService.MODE_STEAM || SessionState.steamUi == "desktop"
}
/** The picture's rectangle inside the view: where the pointer may go. */
private fun drawnRect(): android.graphics.RectF? {
val width = surfaceView.width.takeIf { it > 0 } ?: return null
val height = surfaceView.height.takeIf { it > 0 } ?: return null
val out = SessionState.outputSize
val scale = minOf(width / out.first.toFloat(), height / out.second.toFloat())
val drawnW = out.first * scale
val drawnH = out.second * scale
val left = (width - drawnW) / 2f
val top = (height - drawnH) / 2f
return android.graphics.RectF(left, top, left + drawnW, top + drawnH)
}
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) return onMouse(event)
if (usingTouchpad()) {
val rect = drawnRect() ?: return false
if (touchpad.bounds != rect) {
val fresh = touchpad.bounds.width() <= 1f
touchpad.bounds = rect
if (fresh) touchpad.place(rect.centerX(), rect.centerY())
}
return touchpad.onTouch(event)
}
return gestures.onTouch(event)
}
/** A mouse: hover moves, buttons press, the wheel scrolls. Android sends buttons as touch
* actions and hover as generic motion, so both paths land here. */
private fun onMouse(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_HOVER_MOVE, MotionEvent.ACTION_MOVE, MotionEvent.ACTION_HOVER_ENTER ->
movePointer(event.x, event.y)
MotionEvent.ACTION_BUTTON_PRESS, MotionEvent.ACTION_BUTTON_RELEASE -> {
movePointer(event.x, event.y)
val button = when (event.actionButton) {
MotionEvent.BUTTON_SECONDARY -> PointerGestures.BTN_RIGHT
MotionEvent.BUTTON_TERTIARY -> PointerGestures.BTN_MIDDLE
else -> PointerGestures.BTN_LEFT
}
WaylandCompositor.nativeSendSceneInput(3, button, if (event.actionMasked == MotionEvent.ACTION_BUTTON_PRESS) 1 else 0)
}
MotionEvent.ACTION_SCROLL -> {
val v = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
if (v != 0f) WaylandCompositor.nativeSendSceneInput(4, -Math.round(v), 0)
}
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP -> {} // the BUTTON_* events carry these
else -> return false
}
return true
}
/** The mode decides which controls appear; `steamdeck-osc` in Downloads still overrides. */
private fun updateOnScreenControls() {
val forced = File(Environment.getExternalStorageDirectory(), "Download/steamdeck-osc")
.takeIf { it.isFile }
?.let { FileUtils.readString(it)?.trim()?.lowercase() }
?: SessionPrefs.oscMode(this)
val controls = onScreenControls ?: return
val buttonsOnly = forced == SessionPrefs.OSC_STEAM_QAM
controls.setButtonsOnly(buttonsOnly)
val show = when (forced) {
SessionPrefs.OSC_ALWAYS -> true
SessionPrefs.OSC_STEAM_QAM -> true
SessionPrefs.OSC_NEVER -> false
else -> !PadBridge.anyControllerConnected()
}
if (show == (controls.visibility == View.VISIBLE)) return
if (!show) controls.releaseAll()
controls.visibility = if (show) View.VISIBLE else View.GONE
Log.i(TAG, "on-screen controls " + when {
!show -> "hidden"
buttonsOnly -> "Steam + QAM"
else -> "full pad"
})
}
// ── Lifecycle ───────────────────────────────────────────────────────────────────────────
override fun onStart() {
super.onStart()
displayManager.registerDisplayListener(displayListener, Handler(Looper.getMainLooper()))
refreshSecondScreenDisplays()
if (secondScreenMode != SecondScreenMode.NONE) showSecondScreen(secondScreenMode)
SessionService.setActivityVisible(this, true)
}
override fun onStop() {
displayManager.unregisterDisplayListener(displayListener)
closeSecondScreen(reset = true)
SessionService.setActivityVisible(this, false)
super.onStop()
}
override fun onNewIntent(intent: android.content.Intent) {
super.onNewIntent(intent)
setIntent(intent)
if (intent.action == SessionService.ACTION_HOME_GUIDE) {
handleHomeGuideIntent(intent)
} else if (intent.action == SessionService.ACTION_RESUME) {
intent.action = null
SessionService.resume(this)
}
}
override fun onResume() {
super.onResume()
refreshHomeApp()
if (intent?.action == SessionService.ACTION_RESUME) {
intent.action = null
SessionService.resume(this)
}
(getSystemService(INPUT_SERVICE) as? InputManager)
?.registerInputDeviceListener(deviceListener, Handler(Looper.getMainLooper()))
updateOnScreenControls()
readPrefs()
if (CompositorHost.isStarted) applyFrameGen()
}
override fun onPause() {
(getSystemService(INPUT_SERVICE) as? InputManager)?.unregisterInputDeviceListener(deviceListener)
// A button held when the app goes away would stay held in the ring for the whole session.
onScreenControls?.releaseAll()
keyboard?.takeIf { it.shown }?.hide()
super.onPause()
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) goFullscreen()
}
private fun goFullscreen() {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
if (Build.VERSION.SDK_INT >= 28) {
window.attributes = window.attributes.apply {
layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
}
}
/** Ours, so a finishing activity never unhooks the one that replaced it. */
private val endListener: (Int) -> Unit = { status -> onSessionEnded(status) }
override fun onDestroy() {
// Deliberately does NOT end the session: this activity can be destroyed while the user is
// in another app, and the whole point of the service is that Steam survives that.
watching = false
pendingBackAction?.let(uiHandler::removeCallbacks)
pendingBackAction = null
closeSecondScreen(reset = true)
if (::hud.isInitialized) hud.stop()
padBridge?.stop()
if (SessionState.endListener === endListener) SessionState.endListener = null
WaylandCompositor.setFirstFrameListener(null)
super.onDestroy()
}
/** Leaving the session sinks its surface back down onto the front end. */
override fun finish() {
super.finish()
overridePendingTransition(R.anim.session_hold, R.anim.session_sink)
}
companion object {
private const val TAG = "SessionActivity"
private const val BACK_DOUBLE_PRESS_TIMEOUT_MS = 500L
/** Compositor scale modes (Container.FULLSCREEN_* values): 1 = fit with bars, centred. */
private const val SCALE_FIT = 1
private const val ALIGN_CENTER = 0
}
}