Merge branch 'main' into fix/steam-sd-preallocation

This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-05 11:33:51 -04:00
commit e4bd2af28e
43 files changed
+2170 -497

No files matched your search

+2 -2
View File
@@ -79,8 +79,8 @@ android {
// file out of its own data directory, and this app's entire job is to exec a rootfs:
// proot, gamescope, the Steam client and everything it launches all live under files/.
targetSdkVersion 28
versionCode 10
versionName "0.3.0"
versionCode 11
versionName "0.3.1"
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
buildConfigField "int", "PR_NUMBER", "${prNumber}"
@@ -42,6 +42,7 @@ import com.droiddeck.launcher.ui.ComponentsPage
import com.droiddeck.launcher.session.GameSaves
import com.droiddeck.launcher.session.SessionLogShare
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.SteamRepair
import com.droiddeck.launcher.ui.ProtonPage
import com.droiddeck.launcher.core.CpuCores
import com.droiddeck.launcher.core.PhantomProcessLimit
@@ -110,6 +111,7 @@ class MainActivity : ComponentActivity() {
private var glThread by mutableStateOf(true)
private var noGlError by mutableStateOf(true)
private var steamDeckMode by mutableStateOf(false)
private var steamRepairQueued by mutableStateOf(false)
private var mangoapp by mutableStateOf(true)
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
private var showProtons by mutableStateOf(false)
@@ -304,13 +306,12 @@ class MainActivity : ComponentActivity() {
}
// The mode whose settings dialog is open, with what it shows; refreshed by openModeSettings().
private var settingsMode by mutableStateOf<String?>(null)
private var resolutionCap by mutableStateOf(1080)
private var customResolution by mutableStateOf<Pair<Int, Int>?>(null)
private var resolution by mutableStateOf(com.droiddeck.launcher.session.SessionDisplay.DEFAULT_RESOLUTION)
private var fexPreset by mutableStateOf("")
private var steamChannel by mutableStateOf("steamdeck_publicbeta")
private var runSteamAtStartup by mutableStateOf(false)
private var theme by mutableStateOf("graphite")
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
private var appScale by mutableStateOf(com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE)
private var hdrOn by mutableStateOf(false)
private var fpsLimit by mutableStateOf(0)
private var upscaler by mutableStateOf(0)
@@ -480,6 +481,7 @@ class MainActivity : ComponentActivity() {
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
refreshPhantomStatus()
theme = SessionPrefs.theme(this)
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
// Last start's game list, so the Steam wall is up on the first frame; refresh() replaces it.
steamGames = if (shortcutPicker) emptyList() else com.droiddeck.launcher.frontend.LibraryCache.load(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
@@ -489,7 +491,7 @@ class MainActivity : ComponentActivity() {
applyLauncherFullscreen()
updates.start()
setContent {
DroidDeckTheme(theme) {
DroidDeckTheme(theme, appScale = appScale) {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
val sm = settingsMode
val page: (@Composable () -> Unit)? = when {
@@ -516,6 +518,7 @@ class MainActivity : ComponentActivity() {
lossless = lossless,
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
theme = theme,
appScale = appScale,
isHomeApp = homeAppSelected,
homeScreenEnabled = homeScreenEnabled,
defaultHomeLabel = defaultHomeLabel,
@@ -645,6 +648,10 @@ class MainActivity : ComponentActivity() {
},
onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false },
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
onAppScale = { percent ->
com.droiddeck.launcher.core.AppUiPrefs.setScale(this, percent)
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
},
onLauncherFullscreen = { on ->
SessionPrefs.setLauncherFullscreen(this, on)
launcherFullscreen = on
@@ -1117,7 +1124,8 @@ class MainActivity : ComponentActivity() {
private fun ModeSettingsHost(mode: String) {
ModeSettingsPage(
ModeSettings(
mode = mode, resolutionCap = resolutionCap, customResolution = customResolution, shapeMode = shapeMode,
mode = mode, resolution = resolution,
panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(this),
hdr = hdrOn, hdrReason = hdrReason, fpsLimit = fpsLimit,
upscaler = upscaler, upscaleSharpness = upscaleSharpness,
gpuDrivers = drivers.summary(),
@@ -1140,6 +1148,7 @@ class MainActivity : ComponentActivity() {
syncBackend = if (mode == SessionService.MODE_STEAM) SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback) else null,
steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null,
steamDeckMode = mode == SessionService.MODE_STEAM && steamDeckMode,
steamRepairQueued = steamRepairQueued,
mangoapp = mangoapp,
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
runSteamAtStartup = mode == SessionService.MODE_STEAM && runSteamAtStartup,
@@ -1172,9 +1181,7 @@ class MainActivity : ComponentActivity() {
}
},
onWifiDiscoverySettings = { openWifiDiscoverySettings() },
onResolution = { cap -> SessionPrefs.setResolutionCap(this, mode, cap); resolutionCap = cap },
onCustomResolution = { size -> SessionPrefs.setCustomResolution(this, mode, size); customResolution = size },
onShape = { shape -> SessionPrefs.setShapeMode(this, shape); shapeMode = shape },
onResolution = { value -> SessionPrefs.setResolutionChoice(this, mode, value); resolution = value },
onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on },
onGpuDrivers = { openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.GPU_TAB) },
onFpsLimit = { fps -> SessionPrefs.setFpsLimit(this, mode, fps); fpsLimit = fps },
@@ -1218,6 +1225,7 @@ class MainActivity : ComponentActivity() {
syncFallback = SessionPrefs.syncFallback(this)
},
onSteamChannel = { id -> SessionPrefs.setSteamChannel(this, id); steamChannel = id },
onSteamRepair = { steamRepairQueued = SteamRepair.queue(this) },
onSteamDeckMode = { on ->
SessionPrefs.setSteamDeckMode(this, on)
steamDeckMode = on
@@ -1329,19 +1337,18 @@ class MainActivity : ComponentActivity() {
showProtons = false
showComponents = false
showMapping = false
resolutionCap = SessionPrefs.resolutionCap(this, mode)
customResolution = SessionPrefs.customResolution(this, mode)
resolution = SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this))
fexPreset = SessionPrefs.fexPreset(this)
fastSync = SessionPrefs.fastSync(this)
fsyncFirst = SessionPrefs.fsyncFirst(this)
syncFallback = SessionPrefs.syncFallback(this)
steamChannel = SessionPrefs.steamChannel(this)
steamDeckMode = SessionPrefs.steamDeckMode(this)
steamRepairQueued = SteamRepair.queued(this)
mangoapp = SessionPrefs.mangoapp(this)
steamController = SessionPrefs.steamController(this)
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
addedGamesDirs = SessionPrefs.addedGamesDirs(this)
shapeMode = SessionPrefs.shapeMode(this)
hdrOn = SessionPrefs.hdr(this, mode)
fpsLimit = SessionPrefs.fpsLimit(this, mode)
upscaler = SessionPrefs.upscaler(this)
@@ -59,6 +59,7 @@ import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.LoadingState
import com.droiddeck.launcher.session.PerfHud
import com.droiddeck.launcher.session.PerfMode
import com.droiddeck.launcher.session.SteamRepair
import com.droiddeck.launcher.session.GameEnvironmentStore
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.SessionEvents
@@ -82,7 +83,9 @@ import com.droiddeck.launcher.wayland.CompositorHost
import com.droiddeck.launcher.wayland.WaylandCompositor
import java.io.File
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.ui.DRAWER_PAGES
import com.droiddeck.launcher.ui.SessionDrawerPage
import com.droiddeck.launcher.ui.sessionDrawerPages
import com.droiddeck.launcher.ui.step
import kotlin.math.abs
/**
@@ -158,6 +161,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private val cursorBuf = IntArray(WaylandCompositor.CURSOR_BUF_INTS)
private var cursorSerial = 0
private var clientHidesCursor = false
/**
* A program holds a pointer lock (zwp_locked_pointer_v1: a mouse-look game, through gamescope).
* While it does, a physical mouse is captured and its raw deltas go to the program as relative
* motion. Without capture the deltas are worked out from Android's pointer position, which stops
* at the screen edge, and so did the camera (#153).
*/
private var pointerLocked = false
private val pointerLockListener = WaylandCompositor.PointerLockListener { locked, _, _ ->
uiHandler.post {
if (pointerLocked == locked) return@post
pointerLocked = locked
Log.i(TAG, "pointer lock " + if (locked) "taken by a program" else "released")
updatePointerCapture()
}
}
private var cursorImage by mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null)
private var cursorHotX by mutableStateOf(0)
private var cursorHotY by mutableStateOf(0)
@@ -205,7 +223,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var closeAtOnce = false
/** The drawer's Components tab: the Protons as last read (ComponentsManager). */
private var drawerComponents by mutableStateOf<ComponentsManager.Snapshot?>(null)
private var drawerPage by mutableIntStateOf(0)
private var drawerPage by mutableStateOf(SessionDrawerPage.CONTROLLER)
private var drawerControllerActive by mutableStateOf(false)
private var backActionsInverted by mutableStateOf(false)
/** The on-screen PC keyboard (ui/PcKeyboard): real key presses, Esc and F1 included. */
@@ -225,7 +243,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var fexPreset by mutableStateOf("")
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
private var onScreenButtonsVisible by mutableStateOf(false)
private var secondScreenMode by mutableStateOf(SessionState.secondScreenMode)
private var secondScreenDisplays by mutableStateOf<List<SecondScreenDisplay>>(emptyList())
private var selectedSecondScreenDisplay by mutableStateOf(SessionState.secondScreenDisplay)
@@ -267,10 +285,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onInputDeviceAdded(deviceId: Int) {
logInputDevice("connected", deviceId)
updateOnScreenControls()
updatePointerCapture()
}
override fun onInputDeviceRemoved(deviceId: Int) {
Log.i(TAG, "input device $deviceId disconnected")
updateOnScreenControls()
updatePointerCapture()
}
override fun onInputDeviceChanged(deviceId: Int) = updateOnScreenControls()
}
@@ -321,7 +341,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// 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)
// Captured mouse events go down the focus chain, and focus sits somewhere in the overlay:
// the root sees every one of them whichever view that is.
val root = object : FrameLayout(this) {
override fun dispatchCapturedPointerEvent(event: MotionEvent): Boolean =
onCapturedPointer(event) || super.dispatchCapturedPointerEvent(event)
override fun dispatchPointerCaptureChanged(hasCapture: Boolean) {
super.dispatchPointerCaptureChanged(hasCapture)
Log.i(TAG, "pointer capture " + if (hasCapture) "on" else "off")
}
}
surfaceView = SurfaceView(this)
surfaceView.holder.addCallback(this)
root.addView(surfaceView)
@@ -384,6 +413,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
updateOnScreenControls()
WaylandCompositor.setFirstFrameListener(firstFrameListener)
WaylandCompositor.setPointerLockListener(pointerLockListener)
SessionEvents.markReadyIfPossible()
SessionState.endListener = endListener
// A single Back opens the session menu; two quick presses/swipes send the Steam QAM chord.
@@ -414,6 +444,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// this full-screen host and remain as a white veil after the drawer closes.
defaultFocusHighlightEnabled = false
setContent {
// The drawer, the PC keyboard and picture in picture take the mouse back.
androidx.compose.runtime.LaunchedEffect(drawerOpen, pcKeyboardOpen, pipUi) { updatePointerCapture() }
DroidDeckTheme {
if (pipUi) {
if (SessionState.suspended) androidx.compose.foundation.layout.Box(
@@ -474,9 +506,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
effects = effects, textureAnisotropy = textureAnisotropy, textureLodBias = textureLodBias,
frameGen = frameGen,
lossless = lossless,
oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode,
oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy, touchMode = touchMode,
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
shapeMode = shapeMode, fexPreset = fexPreset,
fexPreset = fexPreset,
secondScreenMode = secondScreenMode,
secondScreenDisplays = secondScreenDisplays,
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
@@ -528,7 +560,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionService.suspendPolicyChanged(this@SessionActivity)
},
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,
@@ -756,7 +787,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
fexPreset = SessionPrefs.fexPreset(this)
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
oscMode = SessionPrefs.oscMode(this)
shapeMode = SessionPrefs.shapeMode(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
}
@@ -1012,38 +1042,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private fun outputSize(): Pair<Int, Int> {
val bounds = panelBounds()
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.
// "Match screen" 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 }
// An emulator whose frames cost next to nothing (Library.drawsAtPanel) gets the panel,
// unless the user chose a resolution for the mode.
val panel = com.droiddeck.launcher.session.SessionDisplay.panelSize(this)
val program = intent.getStringExtra(SessionService.EXTRA_PROGRAM)
val cap = if (intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_RUN &&
Library.drawsAtPanel(program) && !SessionPrefs.resolutionChosen(this, mode)) 0
else 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())
// Lightweight emulators keep their panel-sized default until a resolution is chosen.
val choice = if (intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_RUN &&
Library.drawsAtPanel(program) && !SessionPrefs.resolutionChosen(this, mode))
com.droiddeck.launcher.session.SessionDisplay.MATCH_SCREEN
else SessionPrefs.resolutionChoice(this, mode, panel)
return com.droiddeck.launcher.session.SessionDisplay.resolveChoice(panel, choice)
}
/**
@@ -1261,7 +1268,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (drawerOpen) {
if (fromController && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
drawerPage = (drawerPage + if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else DRAWER_PAGES - 1) % DRAWER_PAGES
drawerPage = sessionDrawerPages(SessionState.mode == SessionService.MODE_STEAM && secondScreenDisplays.isNotEmpty())
.step(drawerPage, if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else -1)
releaseDrawerDirection()
}
return true
@@ -1348,6 +1356,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return super.dispatchGenericMotionEvent(event)
}
/** With no view focused, captured mouse events skip the root and arrive here as trackball events. */
override fun dispatchTrackballEvent(event: MotionEvent): Boolean =
onCapturedPointer(event) || super.dispatchTrackballEvent(event)
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
if (pipUi) return true
if (event.actionMasked == MotionEvent.ACTION_DOWN && event.isFromSource(InputDevice.SOURCE_TOUCHSCREEN)) {
@@ -1698,6 +1710,55 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return true
}
/** A physical mouse, not a touchscreen, stylus or the system's own virtual devices. */
private fun mouseAttached(): Boolean = InputDevice.getDeviceIds().any { id ->
val d = InputDevice.getDevice(id)
d != null && !d.isVirtual && d.supportsSource(InputDevice.SOURCE_MOUSE)
}
/** Capture the mouse exactly while a program holds a lock and the session has the mouse. */
private fun updatePointerCapture() {
if (!::surfaceView.isInitialized) return
val view = window.decorView
val want = pointerLocked && resumed && hasWindowFocus() && !drawerOpen && !pcKeyboardOpen && !pipUi && mouseAttached()
if (want == view.hasPointerCapture()) return
if (want) view.requestPointerCapture() else view.releasePointerCapture()
}
/**
* A captured mouse: raw motion as relative pointer deltas, buttons and the wheel as on the
* uncaptured path. Anything else (a captured touchpad's absolute fingers) is left alone.
*/
private fun onCapturedPointer(event: MotionEvent): Boolean {
if (!pointerLocked || !event.isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE)) return false
when (event.actionMasked) {
MotionEvent.ACTION_MOVE -> {
var dx = event.x
var dy = event.y
for (i in 0 until event.historySize) {
dx += event.getHistoricalX(i)
dy += event.getHistoricalY(i)
}
WaylandCompositor.sendPointerDelta(dx, dy)
}
MotionEvent.ACTION_BUTTON_PRESS, MotionEvent.ACTION_BUTTON_RELEASE -> {
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; `droiddeck-osc` in Downloads still overrides. */
/** Which controller the session has, so a pad report says what was plugged in and when. */
private fun logInputDevice(what: String, deviceId: Int) {
@@ -1714,6 +1775,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
?: SessionPrefs.oscMode(this)
val controls = onScreenControls ?: return
if (pipUi) {
onScreenButtonsVisible = false
controls.releaseAll()
controls.visibility = View.GONE
return
@@ -1729,6 +1791,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// started from the rail, or Steam, gets it; the drawer turns it on anywhere.
else -> !PadBridge.anyControllerConnected() && SessionState.mode != SessionService.MODE_DESKTOP
}
onScreenButtonsVisible = show
if (show == (controls.visibility == View.VISIBLE)) return
if (!show) controls.releaseAll()
controls.visibility = if (show) View.VISIBLE else View.GONE
@@ -1867,6 +1930,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
controllerSettings = ControllerPrefs.read(this)
updateOnScreenControls()
resumed = true
updatePointerCapture()
if (!pipUi) sessionClipboard.start()
updatePadMotion()
readPrefs()
@@ -1886,6 +1950,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// A button held when the app goes away would stay held in the ring for the whole session.
onScreenControls?.releaseAll()
resumed = false
updatePointerCapture()
updatePadMotion()
keyboard?.takeIf { it.shown }?.hide()
super.onPause()
@@ -1906,6 +1971,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
goFullscreen()
refreshClipboard()
}
// Android drops a capture with the focus; a lock still held takes it again on return.
updatePointerCapture()
}
/** The keyboard/trackpad presentation can own focus instead of the main display. */
@@ -1933,6 +2000,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private val endListener: (Int) -> Unit = { status -> onSessionEnded(status) }
private val firstFrameListener = Runnable {
SessionEvents.firstFrame()
if (SessionState.mode == SessionService.MODE_STEAM) SteamRepair.clientShown(this)
runOnUiThread {
loading.visible = false
hud.start()
@@ -1955,6 +2023,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null
if (SessionState.endListener === endListener) SessionState.endListener = null
WaylandCompositor.clearFirstFrameListener(firstFrameListener)
WaylandCompositor.clearPointerLockListener(pointerLockListener)
super.onDestroy()
}
@@ -0,0 +1,19 @@
package com.droiddeck.launcher.core
import android.content.Context
object AppUiPrefs {
const val DEFAULT_SCALE = 100
val scales = listOf(75, 85, 100, 115, 125, 150)
internal fun normalizeScale(percent: Int): Int = percent.takeIf { it in scales } ?: DEFAULT_SCALE
fun scale(context: Context): Int = normalizeScale(
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).getInt("scale", DEFAULT_SCALE),
)
fun setScale(context: Context, percent: Int) {
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).edit()
.putInt("scale", normalizeScale(percent)).apply()
}
}
@@ -108,9 +108,9 @@ object DeviceReport {
h("Display")
k("Session output", SessionState.outputSize?.let { "${it.first}x${it.second}" })
k("Session refresh", String.format(Locale.US, "%.2f Hz", SessionState.refreshHz))
k("Shape setting", SessionPrefs.shapeMode(context))
k("Custom resolution (Steam)", SessionPrefs.customResolution(context, SessionService.MODE_STEAM)?.let { "${it.first}x${it.second}" } ?: "off")
k("Custom resolution (Desktop)", SessionPrefs.customResolution(context, SessionService.MODE_DESKTOP)?.let { "${it.first}x${it.second}" } ?: "off")
val panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(context)
k("Resolution (Steam)", SessionPrefs.resolutionChoice(context, SessionService.MODE_STEAM, panelSize))
k("Resolution (Desktop)", SessionPrefs.resolutionChoice(context, SessionService.MODE_DESKTOP, panelSize))
k("Foldable", context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle"))
h("Drivers")
@@ -22,7 +22,7 @@ class FileManagerActivity : ComponentActivity() {
}
val initialDir = intent.getStringExtra(EXTRA_START_DIR)?.let(::File)
setContent {
DroidDeckTheme {
DroidDeckTheme(appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)) {
Surface(modifier = Modifier.fillMaxSize().systemBarsPadding(), color = MaterialTheme.colorScheme.background) {
FileManagerScreen(initialDir = initialDir)
}
@@ -43,7 +43,7 @@ class FilePickerActivity : ComponentActivity() {
?.let { File(it) }?.takeIf { it.isDirectory }
setContent {
DroidDeckTheme {
DroidDeckTheme(appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)) {
Surface(modifier = Modifier.fillMaxSize().systemBarsPadding(), color = MaterialTheme.colorScheme.background) {
FileManagerScreen(
pickMode = true,
@@ -0,0 +1,39 @@
package com.droiddeck.launcher.input;
import java.io.File;
/**
* Where the fake input rings live and how the guest is told about them. Kept apart from
* {@link FakeInputWriter}, which loads native code, so it can be tested on the JVM.
*/
final class FakeInputRingPaths {
static final String RING_DIR_NAME = "fakeinput-rings";
private FakeInputRingPaths() {}
/** The rings sit beside the fake evdev nodes: session/dev/input -> session/dev/fakeinput-rings. */
static File ringDir(File fakeInputDir) {
if (fakeInputDir == null) {
return null;
}
File inputDir = fakeInputDir.getAbsoluteFile();
File parent = inputDir.getParentFile();
return new File(parent != null ? parent : inputDir, RING_DIR_NAME);
}
static File ringFile(File fakeInputDir, int slot) {
File ringDir = ringDir(fakeInputDir);
return ringDir != null ? new File(ringDir, "ring" + slot) : null;
}
/**
* The ring's path as the guest opens it. The session tree is bound into the guest at its own
* host path, spelled the way {@code Context.getFilesDir()} spells it (LinuxRuntime.binds), so
* the export has to use that same spelling. A canonical path is not that: where
* /data/user/0 is a link, or a separate mount, onto /data/data (ColorOS 16, #166) it resolves
* to a prefix nothing is bound at, and every pad, physical and on-screen, goes dead.
*/
static String exportPath(File ringFile) {
return ringFile.getAbsolutePath();
}
}
@@ -94,7 +94,6 @@ public class FakeInputWriter {
private static final int RING_SNAPSHOT_SEQ_OFFSET = 32;
private static final int RING_SNAPSHOT_BUTTONS_OFFSET = 40;
private static final int RING_SNAPSHOT_AXES_OFFSET = 44; // short[8]
private static final String RING_DIR_NAME = "fakeinput-rings";
// After the events - MUST match the block in fakeinput_steam.cpp (DeckImu): what a Deck has and
// an Xbox pad does not - motion, back grips, trackpads - in the Steam Deck controller's axes and
// units, under a seqlock like the snapshot's. Only libfakeinput's Deck controller reads it.
@@ -198,26 +197,8 @@ public class FakeInputWriter {
return builder.toString();
}
private static File getRingDir(File fakeInputDir) {
if (fakeInputDir == null) {
return null;
}
File inputDir = fakeInputDir.getAbsoluteFile();
File parent = inputDir.getParentFile();
return new File(parent != null ? parent : inputDir, RING_DIR_NAME);
}
private static File getRingFile(File fakeInputDir, int slot) {
File ringDir = getRingDir(fakeInputDir);
return ringDir != null ? new File(ringDir, "ring" + slot) : null;
}
private static String getCanonicalOrAbsolutePath(File file) {
try {
return file.getCanonicalPath();
} catch (IOException e) {
return file.getAbsolutePath();
}
return FakeInputRingPaths.ringFile(fakeInputDir, slot);
}
private static void initializeRingHeader(ByteBuffer data) {
@@ -325,7 +306,7 @@ public class FakeInputWriter {
ringSlot.ringFile = ringFile.getAbsoluteFile();
ringSlot.ringRaf = raf;
ringSlot.ringChannel = channel;
ringSlot.exportPath = getCanonicalOrAbsolutePath(ringFile);
ringSlot.exportPath = FakeInputRingPaths.exportPath(ringFile);
Log.i(TAG, "Created fake input file ring for slot " + slot + ": " + ringSlot.exportPath);
return ringSlot;
} catch (IOException e) {
@@ -76,6 +76,17 @@ public final class PadBridge {
private final java.util.Set<Integer> seenDevices = new java.util.HashSet<>();
private int lastDeviceId = Integer.MIN_VALUE;
/** No physical controller is driving the pad: nothing yet, or the on-screen controls were last. */
public static final int NO_CONTROLLER = -1;
/**
* The Android input device id of the physical controller that last drove the pad, or
* {@link #NO_CONTROLLER}. Every pad feeds the one exported slot, so this is how a game's rumble
* on that slot finds the motors in the player's hands (RumbleComponent).
*/
private static volatile int activeControllerId = NO_CONTROLLER;
public static int activeControllerId() { return activeControllerId; }
public PadBridge(File fakeInputDir) {
writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), SLOT);
}
@@ -90,6 +101,7 @@ public final class PadBridge {
}
public synchronized void stop() {
activeControllerId = NO_CONTROLLER;
systemGuidePressed = false;
systemQamPressed = false;
qamChordActive = false;
@@ -218,6 +230,7 @@ public final class PadBridge {
*/
public synchronized void applyTouch(java.util.function.Consumer<PadState> mutation) {
mutation.accept(state);
activeControllerId = NO_CONTROLLER;
notePlayerInput();
statOnScreen++;
scheduleStats();
@@ -299,6 +312,7 @@ public final class PadBridge {
/** Per event, so the common case - the same pad as last time - is a single compare. */
private void noteDevice(InputDevice device) {
activeControllerId = device.getId();
if (device.getId() == lastDeviceId) return;
lastDeviceId = device.getId();
statDevice = device.getName();
@@ -14,7 +14,7 @@ import kotlin.math.abs
* The client reads `/sys/class/power_supply/BAT<n>/...` - a laptop's or a Deck's naming and files
* (`energy_now`, `power_now`, `time_to_empty_now`, a `uevent` of `POWER_SUPPLY_*` lines). Android
* names its supply `battery` and lays it out differently, and on many phones an app may not read
* it at all, so the client showed no battery. This writes a `BAT0` (and a `BAT1`, the Deck's name)
* it at all, so the client showed no battery. This writes one `BAT1` (the Deck's name)
* from Android's own battery API - the same on every device - into a directory the session binds
* over `/sys/class/power_supply`, refreshed every few seconds while the session runs.
*
@@ -31,6 +31,8 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
private var thread: Thread? = null
override fun start() {
// Older builds exported the same aggregate battery twice; MangoHud counts both supplies.
File(dir, "BAT0").deleteRecursively()
running = true
write()
thread = Thread({
@@ -39,7 +41,7 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
if (running) write()
}
}, "battery-sysfs").apply { isDaemon = true; start() }
Log.i(TAG, "battery: BAT0/BAT1 written to ${dir.path} every ${PERIOD_MS / 1000} s")
Log.i(TAG, "battery: BAT1 written to ${dir.path} every ${PERIOD_MS / 1000} s")
}
override fun stop() {
@@ -87,7 +89,7 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
pct <= 15 -> "Low"
else -> "Normal"
}
for (name in listOf("BAT0", "BAT1")) {
for (name in listOf("BAT1")) {
val bat = File(dir, name).apply { mkdirs() }
val attrs = linkedMapOf(
"type" to "Battery",
@@ -3,43 +3,58 @@ package com.droiddeck.launcher.session
import android.net.LocalServerSocket
import android.content.SharedPreferences
import android.os.Build
import android.os.CombinedVibration
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.util.Log
import android.view.InputDevice
import androidx.annotation.RequiresApi
import com.droiddeck.launcher.core.SessionPart
import com.droiddeck.launcher.input.ControllerPrefs
import com.droiddeck.launcher.input.PadBridge
import java.io.DataInputStream
import kotlin.math.max
/**
* Rumble for the on-screen pad, on the phone itself.
* Rumble for the pad, on the motors in the player's hands.
*
* When a game plays a force-feedback effect on the fake pad, the fake evdev layer inside the guest
* connects to the abstract socket [NAME] and sends one packet: strong, weak, duration in ms and
* the pad slot, four little-endian 16-bit values. This listens for those and drives the device's
* vibrator with the stronger motor's strength for the effect's duration. Best effort both ways:
* a missing listener costs the guest nothing, and a bad packet is dropped.
* When a game plays a force-feedback effect on the fake pad (or Steam Input sends the Deck's
* rumble report), the fake evdev layer inside the guest connects to the abstract socket [NAME]
* and sends one packet: strong, weak, duration in ms and the pad slot, four little-endian 16-bit
* values. This listens for those and plays the effect on the physical controller that last drove
* the pad (PadBridge.activeControllerId) when Android exposes its motors - strong and weak
* separately where it has two - and otherwise on the device's own vibrator, with the stronger
* motor's strength: the on-screen pad, or a controller without rumble. Best effort both ways: a
* missing listener costs the guest nothing, and a bad packet is dropped.
*/
class RumbleComponent : SessionPart() {
@Volatile private var server: LocalServerSocket? = null
private var vibrator: Vibrator? = null
private var phone: Motors? = null
/** Where the last effect went, so the next one (or a stop) can end it there. */
private var playing: Motors? = null
private var controllerId = PadBridge.NO_CONTROLLER
private var controller: Motors? = null
private var preferences: SharedPreferences? = null
private var enabled = false
private val preferenceListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == null || key == "rumble") refreshEnabled()
}
/** The motors of the controller with this input device id, or null when it has none. Tests swap it. */
internal var controllerMotors: (Int) -> Motors? = ::lookupController
@Synchronized private fun refreshEnabled() {
enabled = app()?.let { ControllerPrefs.rumbleEnabled(it) } == true
if (!enabled) vibrator?.cancel()
if (!enabled) { playing?.cancel(); playing = null }
}
override fun start() {
val ctx = app() ?: return
val s = try { LocalServerSocket(NAME) } catch (e: Exception) { Log.w(TAG, "rumble: no listener ($e)"); return }
vibrator = if (Build.VERSION.SDK_INT >= 31) ctx.getSystemService(VibratorManager::class.java)?.defaultVibrator
val vibrator = if (Build.VERSION.SDK_INT >= 31) ctx.getSystemService(VibratorManager::class.java)?.defaultVibrator
else @Suppress("DEPRECATION") ctx.getSystemService(Vibrator::class.java)
phone = vibrator?.takeIf { it.hasVibrator() }?.let { PhoneMotors(it) }
preferences = ControllerPrefs.prefs(ctx).also { it.registerOnSharedPreferenceChangeListener(preferenceListener) }
refreshEnabled()
server = s
@@ -49,8 +64,7 @@ class RumbleComponent : SessionPart() {
try {
val bytes = ByteArray(8)
DataInputStream(client.inputStream).readFully(bytes)
val strong = u16(bytes, 0); val weak = u16(bytes, 2); val ms = u16(bytes, 4)
buzz(max(strong, weak), ms)
buzz(u16(bytes, 0), u16(bytes, 2), u16(bytes, 4))
} catch (e: Exception) {
// A partial packet or a closed peer: nothing to play.
} finally {
@@ -65,8 +79,11 @@ class RumbleComponent : SessionPart() {
preferences?.unregisterOnSharedPreferenceChangeListener(preferenceListener)
preferences = null
enabled = false
vibrator?.cancel()
vibrator = null
playing?.cancel()
playing = null
phone = null
controller = null
controllerId = PadBridge.NO_CONTROLLER
val s = server
server = null
try { s?.close() } catch (e: Exception) { /* the accept loop ends on the next wake */ }
@@ -74,24 +91,91 @@ class RumbleComponent : SessionPart() {
private fun u16(b: ByteArray, at: Int): Int = (b[at].toInt() and 0xFF) or ((b[at + 1].toInt() and 0xFF) shl 8)
@Synchronized private fun buzz(strength: Int, ms: Int) {
@Synchronized private fun buzz(strong: Int, weak: Int, ms: Int) {
if (!enabled) return
val vibrator = vibrator ?: return
if (!vibrator.hasVibrator()) return
if (strength == 0 || ms == 0) { vibrator.cancel(); return }
val amplitude = (strength * 255L / 65535L).toInt().coerceIn(1, 255)
val duration = ms.toLong().coerceIn(1L, 5000L)
try {
if (vibrator.hasAmplitudeControl()) vibrator.vibrate(VibrationEffect.createOneShot(duration, amplitude))
else vibrator.vibrate(VibrationEffect.createOneShot(duration, VibrationEffect.DEFAULT_AMPLITUDE))
} catch (e: Exception) {
Log.w(TAG, "rumble: $e")
val target = target()
if (target !== playing) {
playing?.cancel()
playing = target
target?.let { Log.i(TAG, "rumble: playing on ${it.name}") }
}
if (target == null) return
if ((strong == 0 && weak == 0) || ms == 0) { target.cancel(); return }
try {
target.play(strong, weak, ms.toLong().coerceIn(1L, 5000L))
} catch (e: Exception) {
Log.w(TAG, "rumble: ${target.name}: $e")
}
}
/** The active controller's motors when it has any, else the phone's. */
private fun target(): Motors? {
val id = PadBridge.activeControllerId()
if (id != controllerId) {
controllerId = id
controller = if (id == PadBridge.NO_CONTROLLER) null else try { controllerMotors(id) } catch (e: Exception) { null }
}
return controller ?: phone
}
/** One place an effect can play. Strengths are the evdev 0..65535 magnitudes. */
internal interface Motors {
val name: String
fun play(strong: Int, weak: Int, ms: Long)
fun cancel()
}
private class PhoneMotors(private val vibrator: Vibrator) : Motors {
override val name = "the device's vibrator"
override fun play(strong: Int, weak: Int, ms: Long) = vibrator.vibrate(oneShot(vibrator, max(strong, weak), ms))
override fun cancel() = vibrator.cancel()
}
/** Android 12+: each of the pad's motors on its own, the lower id being the strong one. */
@RequiresApi(31)
private class PadMotors(override val name: String, private val manager: VibratorManager, private val ids: IntArray) : Motors {
override fun play(strong: Int, weak: Int, ms: Long) {
val combined = CombinedVibration.startParallel()
var any = false
fun add(id: Int, strength: Int) {
if (strength == 0) return
combined.addVibrator(id, oneShot(manager.getVibrator(id), strength, ms))
any = true
}
if (ids.size >= 2) { add(ids[0], strong); add(ids[1], weak) } else add(ids[0], max(strong, weak))
if (any) manager.vibrate(combined.combine()) else manager.cancel()
}
override fun cancel() = manager.cancel()
}
/** Before Android 12 a pad has one vibrator. */
private class LegacyPadMotors(override val name: String, private val vibrator: Vibrator) : Motors {
override fun play(strong: Int, weak: Int, ms: Long) = vibrator.vibrate(oneShot(vibrator, max(strong, weak), ms))
override fun cancel() = vibrator.cancel()
}
companion object {
private const val TAG = "SessionService"
/** Must match the fake evdev layer (fakeinput_steam.cpp). */
const val NAME = "droiddeck-rumble"
private fun oneShot(vibrator: Vibrator, strength: Int, ms: Long): VibrationEffect {
val amplitude = if (vibrator.hasAmplitudeControl()) (strength * 255L / 65535L).toInt().coerceIn(1, 255)
else VibrationEffect.DEFAULT_AMPLITUDE
return VibrationEffect.createOneShot(ms, amplitude)
}
private fun lookupController(id: Int): Motors? {
val device = InputDevice.getDevice(id) ?: return null
val name = "\"${device.name}\""
if (Build.VERSION.SDK_INT >= 31) {
val manager = device.vibratorManager
val ids = manager.vibratorIds.sortedArray()
return if (ids.isEmpty()) null else PadMotors(name, manager, ids)
}
@Suppress("DEPRECATION")
val vibrator = device.vibrator
return if (vibrator.hasVibrator()) LegacyPadMotors(name, vibrator) else null
}
}
}
@@ -0,0 +1,53 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.view.WindowManager
/** The virtual display advertised to the guest, before it is fitted onto the Android panel. */
object SessionDisplay {
const val MATCH_SCREEN = "screen"
const val DEFAULT_RESOLUTION = "1280x720"
fun screenSize(panel: Pair<Int, Int>): Pair<Int, Int> =
(maxOf(panel.first, panel.second) and 1.inv()) to (minOf(panel.first, panel.second) and 1.inv())
fun resolveChoice(panel: Pair<Int, Int>, choice: String): Pair<Int, Int> =
if (choice == MATCH_SCREEN) screenSize(panel) else {
// Legacy displays may exceed the custom dialog's size limit. Keep their exact size.
val parts = choice.split('x').map { it.toIntOrNull() }
if (parts.size == 2 && parts.all { it != null && it > 0 }) parts[0]!! to parts[1]!!
else 1280 to 720
}
/** Fixed dimensions and the current panel, with no duplicate panel-size preset. */
fun resolutionOptions(panel: Pair<Int, Int>): List<String> =
listOf(DEFAULT_RESOLUTION, "1600x900", "1920x1080")
.filter { resolveChoice(panel, it) != screenSize(panel) } + MATCH_SCREEN
fun panelSize(context: Context): Pair<Int, Int> {
val manager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@Suppress("DEPRECATION")
val mode = manager.defaultDisplay.mode
return maxOf(mode.physicalWidth, mode.physicalHeight) to minOf(mode.physicalWidth, mode.physicalHeight)
}
/** Interpret an older cap/aspect combination without altering the saved picture size. */
fun resolve(panel: Pair<Int, Int>, cap: Int, shape: String, custom: Pair<Int, Int>? = null): Pair<Int, Int> {
custom?.let { return it }
val panelW = maxOf(panel.first, panel.second).toFloat()
val panelH = minOf(panel.first, panel.second).toFloat().coerceAtLeast(1f)
// Auto retains wide panels but floors squarer ones at 16:9 for game compatibility.
// Match screen removes that floor; fixed 16:9 also keeps foldable sessions stable.
val aspect = when (shape) {
SessionPrefs.SHAPE_WIDE -> 16f / 9f
SessionPrefs.SHAPE_EXACT -> panelW / panelH
else -> maxOf(panelW / panelH, 16f / 9f)
}
val height = (if (cap <= 0) panelH else minOf(panelH, cap.toFloat())).toInt()
return ((height * aspect).toInt() and 1.inv()) to (height and 1.inv())
}
/** Mirrors gamescope's even-width 16:9 calculation, including near-16:9 rounding. */
fun canStretch16x9(size: Pair<Int, Int>): Boolean =
(size.second * 16 + 4) / 9 / 2 * 2 > size.first
}
@@ -88,27 +88,16 @@ object SessionPrefs {
const val SHAPE_WIDE = "16:9"
const val SHAPE_EXACT = "exact"
/** The choices the settings offer, in order. */
val shapeChoices = listOf(
SHAPE_AUTO to "Auto (16:9+)",
SHAPE_EXACT to "Match screen",
SHAPE_WIDE to "Always 16:9",
)
/**
* The shape of the display the session presents: the panel's own (never narrower than 16:9),
* exactly the panel's (a 4:3 or 3:2 handheld, drawn edge to edge), or a fixed 16:9. A foldable defaults to 16:9, which sits with modest bars on either of its
* panels; the panel's own shape would fit one and leave a strip on the other, and gamescope's
* display cannot change size once the session is up.
*/
fun shapeMode(context: Context): String =
private fun shapeMode(context: Context): String =
prefs(context).getString("shape", null)
?: if (context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle")) SHAPE_WIDE else SHAPE_AUTO
fun setShapeMode(context: Context, mode: String) {
prefs(context).edit().putString("shape", mode).apply()
}
fun oscMode(context: Context): String = prefs(context).getString("osc", OSC_AUTO) ?: OSC_AUTO
fun setOscMode(context: Context, mode: String) {
@@ -512,11 +501,33 @@ object SessionPrefs {
* and the emulators under it get the same GPU headroom. Read once, when the session's display
* is sized; a cap the user chose wins over the default.
*/
fun resolutionCap(context: Context, mode: String): Int = prefs(context).getInt("resolutionCap.$mode", defaultResolutionCap(mode))
private fun resolutionCap(context: Context, mode: String): Int = prefs(context).getInt("resolutionCap.$mode", 720)
/** Whether the user chose the mode's resolution (a cap or a custom size) rather than the default. */
fun resolutionChosen(context: Context, mode: String): Boolean =
prefs(context).contains("resolutionCap.$mode") || customResolution(context, mode) != null
prefs(context).contains("displayResolution.$mode") ||
prefs(context).contains("resolutionCap.$mode") || customResolution(context, mode) != null
/** One per-mode choice now owns both dimensions; old caps/shapes are read only for migration. */
fun resolutionChoice(context: Context, mode: String, panel: Pair<Int, Int>): String {
val saved = prefs(context)
saved.getString("displayResolution.$mode", null)?.let { value ->
if (value == SessionDisplay.MATCH_SCREEN) return value
parseResolution(value)?.let { return "${it.first}x${it.second}" }
}
if (!resolutionChosen(context, mode) && !saved.contains("shape")) return SessionDisplay.DEFAULT_RESOLUTION
val legacy = SessionDisplay.resolve(panel, resolutionCap(context, mode), shapeMode(context), customResolution(context, mode))
return if (legacy == SessionDisplay.screenSize(panel)) SessionDisplay.MATCH_SCREEN
else "${legacy.first}x${legacy.second}"
}
fun setResolutionChoice(context: Context, mode: String, choice: String) {
val value = if (choice == SessionDisplay.MATCH_SCREEN) choice else {
val size = requireNotNull(parseResolution(choice)) { "Invalid resolution" }
"${size.first}x${size.second}"
}
prefs(context).edit().putString("displayResolution.$mode", value).apply()
}
/**
* The FEXCore preset for the games the client launches (core/FexPreset ids); "" = FEX's defaults.
@@ -596,25 +607,13 @@ object SessionPrefs {
prefs(context).edit().putString("theme", id).apply()
}
/** What a mode gets when nothing was chosen. */
@Suppress("UNUSED_PARAMETER")
fun defaultResolutionCap(mode: String): Int = 720
fun setResolutionCap(context: Context, mode: String, cap: Int) {
prefs(context).edit().putInt("resolutionCap.$mode", cap).apply()
}
/**
* A fixed size for the session's display, per mode, or null. When set it replaces both the
* cap and the shape: the compositor fits it to the panel with bars where the shapes differ.
*/
fun customResolution(context: Context, mode: String): Pair<Int, Int>? =
private fun customResolution(context: Context, mode: String): Pair<Int, Int>? =
parseResolution(prefs(context).getString("customRes.$mode", null))
fun setCustomResolution(context: Context, mode: String, size: Pair<Int, Int>?) {
prefs(context).edit().putString("customRes.$mode", size?.let { "${it.first}x${it.second}" }).apply()
}
/** "1024x768" (or ×, or *) to an even size inside 320x240..3840x2160; anything else is null. */
fun parseResolution(text: String?): Pair<Int, Int>? {
val parts = text?.trim()?.split('x', 'X', '×', '*')?.map { it.trim() } ?: return null
@@ -669,20 +668,27 @@ object SessionPrefs {
/**
* How the compositor resizes the session onto the panel (WaylandCompositor.nativeSetUpscaler's
* modes): Off and Linear both filter bilinearly, Nearest keeps pixels square for 2D and old
* titles, the rest sharpen where the picture is enlarged; FSR (fit) rounds the picture to
* FSR's preferred size first. Sharpen only works at any size.
* modes). Linear is the default; Nearest preserves hard pixel edges. Spatial filters work
* when enlarged; Sharpen only works at any size. The old Off/Linear and FSR/FSR Fit pairs
* are equivalent on Wayland, so saved aliases resolve to one choice.
*/
val upscalerChoices = listOf(
0 to "Off", 1 to "Linear", 2 to "Nearest", 4 to "AMD FSR 1", 5 to "AMD FSR 1 (fit)", 3 to "Snapdragon GSR",
0 to "Linear", 2 to "Nearest", 4 to "AMD FSR 1", 3 to "Snapdragon GSR",
8 to "Snapdragon GSR (quality)", 7 to "NVIDIA NIS", 6 to "Sharpen only",
)
fun upscaler(context: Context): Int =
prefs(context).getInt("upscaler", 0).takeIf { m -> upscalerChoices.any { it.first == m } } ?: 0
fun canonicalUpscaler(mode: Int): Int = when (mode) {
1 -> 0
5 -> 4
else -> mode.takeIf { m -> upscalerChoices.any { it.first == m } } ?: 0
}
fun upscalerHasSharpness(mode: Int): Boolean = canonicalUpscaler(mode) in 3..8
fun upscaler(context: Context): Int = canonicalUpscaler(prefs(context).getInt("upscaler", 0))
fun setUpscaler(context: Context, mode: Int) {
prefs(context).edit().putInt("upscaler", mode).apply()
prefs(context).edit().putInt("upscaler", canonicalUpscaler(mode)).apply()
}
fun upscaleSharpness(context: Context): Int = prefs(context).getInt("upscaleSharpness", 75).coerceIn(0, 100)
@@ -0,0 +1,21 @@
package com.droiddeck.launcher.session
import android.content.Context
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
object SteamRepair {
private fun home(context: Context) = File(LinuxRuntime.rootDir(context), "root")
private fun request(context: Context) = File(home(context), ".bl-steam-repair")
private fun unconfirmed(context: Context) = File(home(context), ".local/share/Steam/package/droiddeck-unconfirmed")
fun queued(context: Context): Boolean = request(context).exists()
fun queue(context: Context): Boolean = runCatching {
request(context).apply { parentFile?.mkdirs() }.writeText("1\n")
}.isSuccess
fun clientShown(context: Context) {
unconfirmed(context).delete()
}
}
@@ -0,0 +1,23 @@
package com.droiddeck.launcher.ui
import android.content.res.Configuration
import androidx.compose.ui.unit.Density
import com.droiddeck.launcher.core.AppUiPrefs
import kotlin.math.roundToInt
internal fun scaledAppDensity(base: Density, percent: Int): Density {
val scale = AppUiPrefs.normalizeScale(percent) / 100f
return if (scale == 1f) base else Density(base.density * scale, base.fontScale)
}
/** Responsive layouts and popup height limits must see the same dp space as the scaled UI. */
internal fun scaledAppConfiguration(base: Configuration, percent: Int): Configuration {
val scale = AppUiPrefs.normalizeScale(percent) / 100f
if (scale == 1f) return base
return Configuration(base).apply {
if (densityDpi > 0) densityDpi = (densityDpi * scale).roundToInt()
if (screenWidthDp > 0) screenWidthDp = (screenWidthDp / scale).roundToInt()
if (screenHeightDp > 0) screenHeightDp = (screenHeightDp / scale).roundToInt()
if (smallestScreenWidthDp > 0) smallestScreenWidthDp = (smallestScreenWidthDp / scale).roundToInt()
}
}
@@ -284,7 +284,7 @@ private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Uni
}
}
/** A page's title, with room at its right for a status chip. */
/** A page's title, with room at its right for controls or status. */
@Composable
internal fun PageHeader(title: String, trailing: @Composable RowScope.() -> Unit = {}) {
Row(
@@ -101,6 +101,7 @@ class FrontEndState(
val gameSyncFolder: String? = null,
val pageKey: String? = null,
val theme: String = Themes.GRAPHITE,
val appScale: Int = com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE,
val isHomeApp: Boolean = false,
val homeScreenEnabled: Boolean = false,
val defaultHomeLabel: String? = null,
@@ -168,6 +169,7 @@ class FrontEndActions(
val onOffline: () -> Unit,
val onPageBack: () -> Unit = {},
val onTheme: (String) -> Unit = {},
val onAppScale: (Int) -> Unit = {},
val onLauncherFullscreen: (Boolean) -> Unit = {},
val onAnimationsEnabled: (Boolean) -> Unit = {},
val onStoreEnabled: (Boolean) -> Unit = {},
@@ -162,8 +162,6 @@ internal fun SetupPanel(
val limitBlocks = PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)
val signedIn = s.offlineAccount != null
var showLimitDetails by rememberSaveable { mutableStateOf(false) }
val checks = 4
val readyCount = listOf(gpuOk, s.ready && !s.busy, !limitBlocks, signedIn).count { it }
// Four tabs instead of one long scroll; LB and RB turn them from anywhere on the page. Build
// and credits are on the Updates page.
val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_session), stringResource(R.string.setup_tab_launcher))
@@ -187,9 +185,8 @@ internal fun SetupPanel(
),
) {
PageHeader(stringResource(R.string.setup_title)) {
Chip(if (readyCount == checks) stringResource(R.string.setup_all_set) else stringResource(R.string.setup_n_ready, readyCount, checks), ok = readyCount == checks)
TabStrip(tabs, tab, pick, Modifier.weight(1f), tabFocus)
}
TabStrip(tabs, tab, pick, Modifier.padding(bottom = 4.dp), tabFocus)
Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) {
when (tab) {
0 -> {
@@ -314,6 +311,15 @@ internal fun SetupPanel(
}
3 -> {
SettingsGroup(stringResource(R.string.setup_launcher)) {
ChoiceRow(
host, "app-scale", stringResource(R.string.setup_app_scale), stringResource(R.string.setup_app_scale_hint),
com.droiddeck.launcher.core.AppUiPrefs.scales.map { percent ->
percent to stringResource(
if (percent == com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE) R.string.setup_app_scale_default
else R.string.ctrl_percent, percent,
)
}, s.appScale, onPick = a.onAppScale,
)
SettingsRow(stringResource(R.string.setup_theme), stringResource(R.string.setup_theme_hint)) {
Box {
ValueChip(Themes.byId(s.theme).label, host.open == "theme") { host.open = if (host.open == "theme") null else "theme" }
@@ -52,13 +52,13 @@ import kotlinx.coroutines.withContext
// dialog, with each variable's actions in a menu off its value as everywhere else.
@Composable
fun GameEnvironmentRow(modifier: Modifier = Modifier) {
fun GameEnvironmentRow(modifier: Modifier = Modifier, showHint: Boolean = true) {
var open by remember { mutableStateOf(false) }
// Opened with a pad, the editor starts with focus in it; the dialog is a window of its own and
// would otherwise give the pad nothing to move from.
var byPad by remember { mutableStateOf(false) }
val inputMode = LocalInputModeManager.current
SettingsRow(stringResource(R.string.game_env_title), stringResource(R.string.game_env_hint)) {
SettingsRow(stringResource(R.string.game_env_title), if (showHint) stringResource(R.string.game_env_hint) else null) {
SecondaryButton(stringResource(R.string.game_env_edit), modifier = modifier) {
byPad = inputMode.inputMode == InputMode.Keyboard
open = true
@@ -43,10 +43,8 @@ class DownloadRow(val key: String, val label: String, val detail: String, val pr
class ModeSettings(
val mode: String,
val resolutionCap: Int,
/** A fixed session size, or null for the cap and shape. */
val customResolution: Pair<Int, Int>? = null,
val shapeMode: String,
val resolution: String,
val panelSize: Pair<Int, Int> = 1280 to 720,
val hdr: Boolean,
val hdrReason: String?,
/** The GPU drivers in use, as the row that opens them on the Components page says it. */
@@ -74,13 +72,14 @@ class ModeSettings(
val fexPreset: String? = null,
/** Steam only: SessionPrefs.SYNC_* chosen for Proton games; null outside Steam. */
val syncBackend: String? = null,
/** Steam only: games are stretched to fill the screen (null = not a Steam page). */
/** Steam only: forces game windows fullscreen (null = not a Steam page). */
val forceFullscreen: Boolean? = null,
val stretch16x9: Boolean? = null,
/** Steam only: the client branch forced on the command line. */
val steamChannel: String? = null,
/** Steam only: enable the SteamOS client interface and its performance controls. */
val steamDeckMode: Boolean = false,
val steamRepairQueued: Boolean = false,
/** Steam, Deck mode: the QAM's performance overlay (mangoapp) is started. */
val mangoapp: Boolean = true,
/** Steam only: what the pad is to the client (SessionPrefs.CONTROLLER_*); null outside Steam. */
@@ -112,10 +111,7 @@ class ModeSettings(
class AddedGameRow(val folderPath: String, val folderName: String, val exePath: String, val exeName: String, val candidates: List<Pair<String, String>>)
class ModeSettingsActions(
val onResolution: (Int) -> Unit,
/** Null clears it. */
val onCustomResolution: (Pair<Int, Int>?) -> Unit = {},
val onShape: (String) -> Unit,
val onResolution: (String) -> Unit,
val onHdr: (Boolean) -> Unit,
/** Opens the GPU drivers on the Components page: they are shared by every session. */
val onGpuDrivers: () -> Unit = {},
@@ -139,6 +135,7 @@ class ModeSettingsActions(
val onForceFullscreen: (Boolean) -> Unit = {},
val onStretch16x9: (Boolean) -> Unit = {},
val onSteamChannel: (String) -> Unit = {},
val onSteamRepair: () -> Unit = {},
val onSteamDeckMode: (Boolean) -> Unit = {},
val onMangoapp: (Boolean) -> Unit = {},
val onSteamController: (String) -> Unit = {},
@@ -158,139 +155,158 @@ class ModeSettingsActions(
val onDismiss: () -> Unit,
)
private enum class ModeSettingsTab(val label: Int) {
DISPLAY(R.string.drawer_page_display),
CONTROLS(R.string.drawer_page_controls),
GAMES(R.string.game_settings_title),
AUDIO(R.string.mode_audio),
SESSION(R.string.mode_session),
STEAM(R.string.mode_tab_steam),
}
@Composable
fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
val steam = s.mode == SessionService.MODE_STEAM
val host = rememberMenuHost()
var confirmDeckyRemoval by remember { mutableStateOf(false) }
var explainWifiDiscovery by remember { mutableStateOf(false) }
val pageScroll = androidx.compose.foundation.rememberScrollState()
var tabIndex by androidx.compose.runtime.saveable.rememberSaveable(s.mode) { mutableStateOf(0) }
val tabs = if (steam) ModeSettingsTab.entries else listOf(
ModeSettingsTab.DISPLAY, ModeSettingsTab.CONTROLS, ModeSettingsTab.SESSION,
)
val tab = tabs[tabIndex]
val scrolls = tabs.map { androidx.compose.foundation.rememberScrollState() }
val tabFocus = remember(s.mode) { tabs.map { androidx.compose.ui.focus.FocusRequester() } }
val firstChip = remember { androidx.compose.ui.focus.FocusRequester() }
androidx.compose.runtime.LaunchedEffect(Unit) {
// One frame first: the box has to be laid out before it can take focus. Opened from the
// cog, focus starts on the first control (Resolution) so the d-pad works at once.
fun selectTab(index: Int) { host.open = null; tabIndex = index }
androidx.compose.runtime.LaunchedEffect(steam, tabIndex) {
androidx.compose.runtime.withFrameNanos { }
runCatching { firstChip.requestFocus() }
runCatching {
if (tab == ModeSettingsTab.DISPLAY) firstChip.requestFocus()
else tabFocus[tabIndex].requestFocus()
}
}
SettingsPage(
host,
title = if (steam) stringResource(R.string.mode_steam_title) else stringResource(R.string.mode_desktop_title),
title = null,
onBack = a.onDismiss,
scroll = pageScroll,
scroll = scrolls[tabIndex],
modifier = Modifier.bumpers(
onPrevious = { selectTab((tabIndex + tabs.size - 1) % tabs.size) },
onNext = { selectTab((tabIndex + 1) % tabs.size) },
),
header = {
TabStrip(tabs.map { stringResource(it.label) }, tabIndex, ::selectTab,
modifier = Modifier.fillMaxWidth(), focusRequesters = tabFocus)
},
) {
SettingsGroup(stringResource(R.string.mode_display)) {
val default = SessionPrefs.defaultResolutionCap(s.mode)
var editCustom by remember { mutableStateOf(false) }
val custom = s.customResolution
ChoiceRow(
host, "res", stringResource(R.string.mode_resolution), stringResource(R.string.common_applies_next_session),
listOf(720 to stringResource(R.string.mode_res_720), 900 to stringResource(R.string.mode_res_900), 1080 to stringResource(R.string.mode_res_1080), 0 to stringResource(R.string.mode_res_native))
.map { (cap, label) -> cap to (if (cap == default) stringResource(R.string.mode_res_default, label) else label) } +
(CUSTOM to (custom?.let { stringResource(R.string.mode_res_custom_value, it.first, it.second) } ?: stringResource(R.string.mode_res_custom))),
if (custom != null) CUSTOM else s.resolutionCap, note = stringResource(R.string.mode_res_note),
chipModifier = androidx.compose.ui.Modifier.focusRequester(firstChip),
onPick = { v -> if (v == CUSTOM) editCustom = true else { a.onCustomResolution(null); a.onResolution(v) } },
)
ChoiceRow(
host, "shape", stringResource(R.string.mode_ratio),
if (custom != null) stringResource(R.string.mode_ratio_custom) else stringResource(R.string.mode_ratio_auto),
com.droiddeck.launcher.session.SessionPrefs.shapeChoices, s.shapeMode, enabled = custom == null, onPick = a.onShape,
)
ChoiceRow(
host, "fps", stringResource(R.string.mode_fps), stringResource(R.string.common_applies_next_session),
com.droiddeck.launcher.session.SessionPrefs.fpsLimitChoices, s.fpsLimit,
note = stringResource(R.string.mode_fps_note),
onPick = a.onFpsLimit,
)
ChoiceRow(
host, "upscaler", stringResource(R.string.drawer_scaling), "How the picture is resized to the screen; the sharpening modes sharpen where it is enlarged.",
com.droiddeck.launcher.session.SessionPrefs.upscalerChoices, s.upscaler,
note = "The sharpening modes work only when the session is smaller than the screen; Linear, Nearest and Sharpen only work at any size. Costs a little GPU time.",
onPick = a.onUpscaler,
)
SliderRow(
stringResource(R.string.drawer_scaling_sharpness), null, s.upscaleSharpness, 0..100, step = 5,
enabled = s.upscaler != 0, format = { "$it%" }, onChange = a.onUpscaleSharpness,
)
if (editCustom) CustomResolutionDialog(
initial = custom,
onSave = { size -> editCustom = false; a.onCustomResolution(size) },
onDismiss = { editCustom = false },
)
}
SettingsGroup(stringResource(R.string.mode_hdr)) {
ToggleRow(
host, "hdr", stringResource(R.string.mode_hdr10),
s.hdrReason?.let { stringResource(R.string.mode_hdr_unavailable, it) }
?: stringResource(R.string.mode_hdr_restart),
checked = s.hdr && s.hdrReason == null, enabled = s.hdrReason == null, onChange = a.onHdr,
)
}
SettingsGroup(stringResource(R.string.mode_drivers)) {
SettingsRow(stringResource(R.string.mode_gpu_drivers), stringResource(R.string.mode_gpu_drivers_hint)) {
ValueChip(s.gpuDrivers, open = false) { a.onGpuDrivers() }
if (tab == ModeSettingsTab.DISPLAY) {
val displaySize = com.droiddeck.launcher.session.SessionDisplay.resolveChoice(s.panelSize, s.resolution)
SettingsGroup(stringResource(R.string.display_session)) {
ResolutionRow(host, s.resolution, s.panelSize, a.onResolution,
chipModifier = Modifier.focusRequester(firstChip))
if (s.stretch16x9 != null && (com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(s.panelSize) ||
com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize))) {
val canStretch = com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize)
ChoiceRow(host, "game-fit", stringResource(R.string.display_game_fit),
stringResource(if (canStretch) R.string.display_game_fit_hint else R.string.display_game_fit_unavailable),
listOf(false to stringResource(R.string.display_preserve), true to stringResource(R.string.display_stretch)),
s.stretch16x9 && canStretch, enabled = canStretch, onPick = a.onStretch16x9)
}
}
SettingsGroup(stringResource(R.string.display_image_scaling)) {
ChoiceRow(
host, "upscaler", stringResource(R.string.display_filter), null,
SessionPrefs.upscalerChoices, s.upscaler,
note = stringResource(R.string.display_filter_note), onPick = a.onUpscaler,
)
if (SessionPrefs.upscalerHasSharpness(s.upscaler)) SliderRow(
stringResource(R.string.display_sharpness), null, s.upscaleSharpness, 0..100, step = 5,
format = { "$it%" }, onChange = a.onUpscaleSharpness,
)
}
SettingsGroup(stringResource(R.string.display_frame_rate)) {
ChoiceRow(host, "fps", stringResource(R.string.mode_fps), stringResource(R.string.common_applies_next_session),
SessionPrefs.fpsLimitChoices, s.fpsLimit, note = stringResource(R.string.mode_fps_note), onPick = a.onFpsLimit)
}
SettingsGroup(stringResource(R.string.mode_hdr)) {
ToggleRow(
host, "hdr", stringResource(R.string.mode_hdr10),
s.hdrReason?.let { stringResource(R.string.mode_hdr_unavailable, it) }
?: stringResource(R.string.mode_hdr_restart),
checked = s.hdr && s.hdrReason == null, enabled = s.hdrReason == null, onChange = a.onHdr,
)
}
SettingsGroup(stringResource(R.string.mode_drivers)) {
SettingsRow(stringResource(R.string.mode_gpu_drivers), stringResource(R.string.mode_gpu_drivers_hint)) {
ValueChip(s.gpuDrivers, open = false) { a.onGpuDrivers() }
}
}
}
SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) {
ChoiceRow(
host, "touch", stringResource(R.string.mode_touch), null,
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode,
note = stringResource(R.string.mode_touch_note),
onPick = a.onTouch,
)
if (steam && s.oscMode != null) ChoiceRow(
host, "osc", stringResource(R.string.mode_osc), null,
listOf(
SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto),
SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always),
SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam),
SessionPrefs.OSC_NEVER to stringResource(R.string.common_never),
), s.oscMode,
note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
)
if (steam && s.steamController != null) ChoiceRow(
host, "controller", stringResource(R.string.mode_controller), stringResource(R.string.mode_controller_hint),
listOf(
SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck),
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360),
), s.steamController,
note = stringResource(R.string.mode_controller_note),
onPick = a.onSteamController,
)
if (steam) ChoiceRow(
host, "back-actions", stringResource(R.string.mode_back), SessionPrefs.backActionsOrder(s.backActionsInverted),
listOf(
false to SessionPrefs.BACK_MENU_THEN_QAM,
true to SessionPrefs.BACK_QAM_THEN_MENU,
), s.backActionsInverted, onPick = a.onBackActionsInverted,
)
if (tab == ModeSettingsTab.CONTROLS) {
SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) {
ChoiceRow(
host, "touch", stringResource(R.string.mode_touch), null,
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode,
note = stringResource(R.string.mode_touch_note),
onPick = a.onTouch,
)
if (steam && s.oscMode != null) ChoiceRow(
host, "osc", stringResource(R.string.mode_osc), null,
listOf(
SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto),
SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always),
SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam),
SessionPrefs.OSC_NEVER to stringResource(R.string.common_never),
), s.oscMode,
note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
)
if (steam && s.steamController != null) ChoiceRow(
host, "controller", stringResource(R.string.mode_controller), stringResource(R.string.mode_controller_hint),
listOf(
SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck),
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360),
), s.steamController,
note = stringResource(R.string.mode_controller_note),
onPick = a.onSteamController,
)
if (steam) ChoiceRow(
host, "back-actions", stringResource(R.string.mode_back), SessionPrefs.backActionsOrder(s.backActionsInverted),
listOf(
false to SessionPrefs.BACK_MENU_THEN_QAM,
true to SessionPrefs.BACK_QAM_THEN_MENU,
), s.backActionsInverted, onPick = a.onBackActionsInverted,
)
}
}
SettingsGroup(stringResource(R.string.mode_session)) {
ChoiceRow(
host, "suspend", stringResource(R.string.mode_suspend),
stringResource(R.string.mode_suspend_hint),
listOf(
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never),
),
s.suspendPolicy,
note = stringResource(R.string.mode_suspend_note),
onPick = a.onSuspendPolicy,
)
if (tab == ModeSettingsTab.SESSION) {
SettingsGroup(stringResource(R.string.mode_session)) {
ChoiceRow(
host, "suspend", stringResource(R.string.mode_suspend),
stringResource(R.string.mode_suspend_hint),
listOf(
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never),
),
s.suspendPolicy,
note = stringResource(R.string.mode_suspend_note),
onPick = a.onSuspendPolicy,
)
}
if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) {
ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null,
s.pipAutoEnter, onChange = a.onPipAutoEnter)
}
if (steam) SettingsGroup(stringResource(R.string.mode_startup)) {
ToggleRow(
host, "steam-startup", stringResource(R.string.mode_steam_startup),
stringResource(R.string.mode_steam_startup_hint),
s.runSteamAtStartup, onChange = a.onRunSteamAtStartup,
)
}
}
if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) {
ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null,
s.pipAutoEnter, onChange = a.onPipAutoEnter)
}
if (steam) SettingsGroup(stringResource(R.string.mode_startup)) {
ToggleRow(
host, "steam-startup", stringResource(R.string.mode_steam_startup),
stringResource(R.string.mode_steam_startup_hint),
s.runSteamAtStartup, onChange = a.onRunSteamAtStartup,
)
}
if (steam) SettingsGroup(stringResource(R.string.mode_decky)) {
if (steam && tab == ModeSettingsTab.STEAM) SettingsGroup(stringResource(R.string.mode_decky)) {
val updateAvailable = s.deckyInstalled != null && s.deckyLatestRelease != null &&
s.deckyInstalled != s.deckyLatestRelease.tag
val status = when {
@@ -349,7 +365,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
)
}
}
if (steam && s.steamChannel != null) SettingsGroup(stringResource(R.string.mode_client)) {
if (steam && tab == ModeSettingsTab.STEAM && s.steamChannel != null) SettingsGroup(stringResource(R.string.mode_client)) {
ToggleRow(
host, "steamdeck", stringResource(R.string.mode_deck_mode),
stringResource(R.string.mode_deck_mode_hint),
@@ -368,8 +384,14 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
note = stringResource(R.string.mode_branch_note),
onPick = a.onSteamChannel,
)
SettingsRow(
stringResource(R.string.mode_steam_repair),
stringResource(if (s.steamRepairQueued) R.string.mode_steam_repair_queued else R.string.mode_steam_repair_hint),
) {
SecondaryButton(stringResource(R.string.mode_steam_repair_button), enabled = !s.steamRepairQueued, onClick = a.onSteamRepair)
}
}
if (steam && s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) {
if (steam && tab == ModeSettingsTab.STEAM && s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) {
val hint = when {
s.wifiDiscovery && !s.wifiDiscoveryLocation -> stringResource(R.string.mode_wifi_location_off)
!s.wifiDiscoveryPermission && s.wifiDiscoveryAsked -> stringResource(R.string.mode_wifi_permission_denied)
@@ -388,7 +410,11 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
}
}
}
if (steam && s.addedGamesDirs != null) SettingsGroup(stringResource(R.string.mode_added_games)) {
if ((tab == ModeSettingsTab.GAMES || (!steam && tab == ModeSettingsTab.DISPLAY)) && s.forceFullscreen != null) SettingsGroup(stringResource(R.string.display_window_compatibility)) {
ToggleRow(host, "fill", stringResource(R.string.display_force_fullscreen),
stringResource(R.string.display_force_fullscreen_hint), s.forceFullscreen, onChange = a.onForceFullscreen)
}
if (steam && tab == ModeSettingsTab.GAMES && s.addedGamesDirs != null) SettingsGroup(stringResource(R.string.mode_added_games)) {
for (dir in s.addedGamesDirs) {
val n = s.addedGames.count { it.folderPath.startsWith("$dir/") }
ActionRow(
@@ -413,7 +439,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
onPick = { path -> if (path == "__pick__") a.onPickAddedGameExe(g.folderPath) else a.onAddedGameExe(g.folderPath, path) },
)
}
if (steam && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) {
if (steam && tab == ModeSettingsTab.GAMES && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) {
if (s.syncBackend != null) SettingsRow(
stringResource(R.string.sync_backend_title),
stringResource(R.string.sync_backend_hint),
@@ -434,18 +460,9 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
note = stringResource(FexPreset.byId(s.fexPreset).detail), onPick = a.onFexPreset,
)
GameEnvironmentRow()
if (s.forceFullscreen != null) ToggleRow(
host, "fill", stringResource(R.string.mode_fill),
stringResource(R.string.mode_fill_hint),
s.forceFullscreen, onChange = a.onForceFullscreen,
)
if (s.stretch16x9 != null) ToggleRow(
host, "stretch169", stringResource(R.string.mode_stretch169),
stringResource(R.string.mode_stretch169_hint),
s.stretch16x9, onChange = a.onStretch16x9,
)
}
if (steam && s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) {
if (steam && tab == ModeSettingsTab.AUDIO && s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) {
ToggleRow(host, "da", stringResource(R.string.mode_directaudio), stringResource(R.string.mode_directaudio_hint), s.directAudio, onChange = a.onDirectAudio)
ChoiceRow(
host, "clientAudio", stringResource(R.string.mode_client_audio), stringResource(R.string.mode_client_audio_hint),
@@ -454,7 +471,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
)
ToggleRow(host, "mic", stringResource(R.string.mode_mic), stringResource(R.string.mode_mic_hint), s.mic, onChange = a.onMic)
}
if (steam && s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) {
if (steam && tab == ModeSettingsTab.GAMES && s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) {
val custom = s.gameStorage.isNotEmpty() && s.gameStorage != "off" && s.storageOptions.none { it.second == s.gameStorage }
val options = buildList {
add("" to (if (s.storageOptions.isEmpty()) stringResource(R.string.mode_storage_auto_none) else stringResource(R.string.mode_storage_auto)))
@@ -490,7 +507,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
onChange = a.onStorageDiagnostics,
)
}
if (!steam && s.renderer != null) SettingsGroup(stringResource(R.string.mode_renderer)) {
if (!steam && tab == ModeSettingsTab.DISPLAY && s.renderer != null) SettingsGroup(stringResource(R.string.mode_renderer)) {
ChoiceRow(
host, "renderer", stringResource(R.string.mode_desktop_renderer), stringResource(R.string.mode_renderer_hint),
listOf("vulkan" to stringResource(R.string.mode_renderer_vulkan), "gles2" to stringResource(R.string.mode_renderer_gles2), "pixman" to stringResource(R.string.mode_renderer_pixman)), s.renderer,
@@ -523,13 +540,10 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
)
}
/** The Resolution menu's "Custom…" entry. */
private const val CUSTOM = -1
/** Width × height for the session, with the common handheld shapes one tap away. */
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
@Composable
private fun CustomResolutionDialog(initial: Pair<Int, Int>?, onSave: (Pair<Int, Int>) -> Unit, onDismiss: () -> Unit) {
internal fun CustomResolutionDialog(initial: Pair<Int, Int>?, onSave: (Pair<Int, Int>) -> Unit, onDismiss: () -> Unit) {
var w by remember { mutableStateOf(initial?.first?.toString() ?: "") }
var h by remember { mutableStateOf(initial?.second?.toString() ?: "") }
val parsed = SessionPrefs.parseResolution("${w}x$h")
@@ -0,0 +1,39 @@
package com.droiddeck.launcher.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
import com.droiddeck.launcher.session.SessionDisplay
/** Pre-session resolution choice, applied when the session starts. */
@Composable
fun ResolutionRow(
host: MenuHost, choice: String, panel: Pair<Int, Int>, onPick: (String) -> Unit,
chipModifier: Modifier = Modifier,
) {
val custom = "custom"
val size = SessionDisplay.resolveChoice(panel, choice)
val screen = SessionDisplay.screenSize(panel)
val choices = SessionDisplay.resolutionOptions(panel)
val selected = if (size == screen) SessionDisplay.MATCH_SCREEN else choice
var editCustom by remember { mutableStateOf(false) }
val options = choices.map { value ->
val dimensions = SessionDisplay.resolveChoice(panel, value)
value to if (value == SessionDisplay.MATCH_SCREEN) stringResource(R.string.display_match_screen, screen.first, screen.second)
else if (value == SessionDisplay.DEFAULT_RESOLUTION) stringResource(R.string.mode_res_default, "${dimensions.first}×${dimensions.second}")
else "${dimensions.first}×${dimensions.second}"
} + (custom to if (selected in choices) stringResource(R.string.mode_res_custom)
else stringResource(R.string.mode_res_custom_value, size.first, size.second))
ChoiceRow(host, "resolution", stringResource(R.string.display_resolution),
stringResource(R.string.common_applies_next_session),
options, if (selected in choices) selected else custom, chipModifier = chipModifier,
onPick = { value -> if (value == custom) editCustom = true else onPick(value) })
if (editCustom) CustomResolutionDialog(size,
onSave = { value -> editCustom = false; onPick("${value.first}x${value.second}") },
onDismiss = { editCustom = false })
}
@@ -0,0 +1,19 @@
package com.droiddeck.launcher.ui
/** Stable page identities keep focus and scroll positions when a secondary display comes and goes. */
internal enum class SessionDrawerPage(val firstControl: String) {
CONTROLLER("touch"),
SECOND_SCREEN("second-screen:none"),
DISPLAY("hud"),
EFFECTS("look"),
GAMES("anisotropy"),
SESSION("suspend"),
}
internal fun sessionDrawerPages(hasSecondScreen: Boolean): List<SessionDrawerPage> =
SessionDrawerPage.entries.filter { hasSecondScreen || it != SessionDrawerPage.SECOND_SCREEN }
internal fun List<SessionDrawerPage>.step(current: SessionDrawerPage, direction: Int): SessionDrawerPage {
val index = indexOf(current).coerceAtLeast(0)
return this[Math.floorMod(index + direction, size)]
}
@@ -70,6 +70,9 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.style.TextOverflow
// The session's loading screen, its paused screen and the HUD text style.
@@ -273,7 +276,11 @@ private fun CancelHint(onCancel: () -> Unit, modifier: Modifier = Modifier) {
.padding(horizontal = 12.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).background(colors.onBackground)) {
Text("B", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.background)
Text("B", color = colors.background, style = TextStyle(
fontSize = 12.sp, lineHeight = 12.sp, fontWeight = FontWeight.Bold,
platformStyle = PlatformTextStyle(includeFontPadding = false),
lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.Both),
))
}
Text(stringResource(R.string.common_cancel), fontSize = 13.sp, color = colors.onSurfaceVariant)
}
@@ -28,15 +28,24 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.material.icons.Icons
import androidx.compose.foundation.layout.heightIn
import androidx.compose.material.icons.outlined.AutoFixHigh
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.MoreHoriz
import androidx.compose.material.icons.outlined.PowerSettingsNew
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.ScreenShare
import androidx.compose.material.icons.outlined.PictureInPictureAlt
import androidx.compose.material.icons.automirrored.outlined.ExitToApp
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.SportsEsports
import androidx.compose.material3.Icon
import androidx.compose.material3.RadioButton
import androidx.compose.material3.RadioButtonDefaults
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.draw.clipToBounds
import android.view.Display
import androidx.activity.compose.BackHandler
@@ -79,6 +88,8 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
@@ -93,7 +104,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
@@ -129,22 +139,33 @@ import com.droiddeck.launcher.input.SecondScreenMode
import kotlinx.coroutines.flow.collect
import java.util.Locale
private val drawerPageTitles = listOf(R.string.drawer_page_display, R.string.drawer_page_effects, R.string.drawer_page_controls, R.string.drawer_page_components, R.string.drawer_page_settings)
private val SessionDrawerPage.title: Int get() = when (this) {
SessionDrawerPage.CONTROLLER -> R.string.drawer_page_controller
SessionDrawerPage.SECOND_SCREEN -> R.string.drawer_second_screen
SessionDrawerPage.DISPLAY -> R.string.drawer_page_display
SessionDrawerPage.EFFECTS -> R.string.drawer_page_effects
SessionDrawerPage.GAMES -> R.string.game_settings_title
SessionDrawerPage.SESSION -> R.string.mode_session
}
/** One icon per drawer page, in page order (QAM-style tabs). */
private val drawerPageIcons = listOf(Icons.Outlined.DesktopWindows, Icons.Outlined.AutoFixHigh, Icons.Outlined.SportsEsports, Icons.Outlined.Layers, Icons.Outlined.Settings)
private val drawerPageEntries = listOf("hud", "look", "touch", "cmp-proton", "suspend")
private val SessionDrawerPage.icon get() = when (this) {
SessionDrawerPage.CONTROLLER -> Icons.Outlined.SportsEsports
SessionDrawerPage.SECOND_SCREEN -> Icons.Outlined.ScreenShare
SessionDrawerPage.DISPLAY -> Icons.Outlined.DesktopWindows
SessionDrawerPage.EFFECTS -> Icons.Outlined.AutoFixHigh
SessionDrawerPage.GAMES -> Icons.Outlined.Layers
SessionDrawerPage.SESSION -> Icons.Outlined.Settings
}
private class DrawerFocus {
private val requesters = HashMap<String, FocusRequester>()
private val last = arrayOfNulls<String>(DRAWER_PAGES)
private val last = mutableMapOf<SessionDrawerPage, String>()
var focused by mutableStateOf<String?>(null)
private set
private fun requester(key: String) = requesters.getOrPut(key) { FocusRequester() }
fun track(page: Int, key: String): Modifier = Modifier.focusRequester(requester(key)).onFocusChanged {
fun track(page: SessionDrawerPage, key: String): Modifier = Modifier.focusRequester(requester(key)).onFocusChanged {
if (it.isFocused) {
last[page] = key
focused = key
@@ -152,9 +173,9 @@ private class DrawerFocus {
}
/** Where focus was last on [page], if anywhere. */
fun target(page: Int) = last[page]
fun target(page: SessionDrawerPage) = last[page]
fun request(key: String) = runCatching { requester(key).requestFocus() }
fun forget(page: Int) { last[page] = null }
fun forget(page: SessionDrawerPage) { last.remove(page) }
}
/** Everything the drawer shows and does. */
@@ -168,13 +189,13 @@ class DrawerActions(
val frameGen: FrameGen.Mode,
val lossless: Lossless.State,
val oscMode: String,
val onScreenButtonsVisible: Boolean,
val suspendPolicy: String,
val backActionsInverted: Boolean,
val touchMode: String,
val touchAuto: String,
val shapeMode: String,
val fexPreset: String,
/** Steam only: games stretched to the screen's size, changed live (null = not Steam). */
/** Steam only: forces game windows fullscreen, changed live (null = not Steam). */
val fillScreen: Boolean? = null,
val upscaler: Int = 0,
val upscaleSharpness: Int = 75,
@@ -204,7 +225,6 @@ class DrawerActions(
val onSuspendPolicy: (String) -> Unit,
val onBackActionsInverted: (Boolean) -> Unit,
val onTouch: (String) -> Unit,
val onShape: (String) -> Unit,
val onFexPreset: (String) -> Unit,
val onFillScreen: (Boolean) -> Unit = {},
val onUpscaler: (Int) -> Unit = {},
@@ -225,7 +245,7 @@ class DrawerActions(
/** Stop with the dialog's button's bounds on screen, for the flood to grow out of; null falls back to [onStop]. */
val onStopFrom: ((androidx.compose.ui.geometry.Rect?) -> Unit)? = null,
val onClose: () -> Unit,
/** Components tab: every Proton with what it uses; null until first read. */
/** Games tab: every Proton with what it uses; null until first read. */
val components: ComponentsManager.Snapshot? = null,
/** Re-reads the Protons (and runs swaps that waited for a game to close). */
val onComponentsRefresh: () -> Unit = {},
@@ -235,16 +255,20 @@ class DrawerActions(
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageChange: (Int) -> Unit, a: DrawerActions) {
internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, controllerActive: Boolean, onPageChange: (SessionDrawerPage) -> Unit, a: DrawerActions) {
val pages = sessionDrawerPages(a.steam && a.secondScreenDisplays.isNotEmpty())
val page = requestedPage.takeIf { it in pages } ?: SessionDrawerPage.CONTROLLER
LaunchedEffect(requestedPage, pages) { if (page != requestedPage) onPageChange(page) }
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val host = rememberMenuHost()
var androidAppsExpanded by rememberSaveable { mutableStateOf(false) }
var advancedEffectsExpanded by rememberSaveable { mutableStateOf(false) }
var appToChooseDisplay by remember { mutableStateOf<HomeApp.LaunchableApp?>(null) }
var confirmStop by remember { mutableStateOf(false) }
var sheetCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
var stopCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
val pageScroll = remember { List(DRAWER_PAGES) { ScrollState(0) } }
val pageScroll = remember { SessionDrawerPage.entries.associateWith { ScrollState(0) } }
val veil by animateFloatAsState(if (open) 1f else 0f, Motion.tw(260), label = "veil")
val focus = remember { DrawerFocus() }
val inputModeManager = LocalInputModeManager.current
@@ -254,13 +278,13 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
BackHandler(enabled = open && confirmStop) { confirmStop = false }
LaunchedEffect(page) { host.open = null; appToChooseDisplay = null }
LaunchedEffect(open, page) { if (open && page == DRAWER_PAGE_COMPONENTS) a.onComponentsRefresh() }
LaunchedEffect(open, page) { if (open && page == SessionDrawerPage.GAMES) a.onComponentsRefresh() }
LaunchedEffect(open, controllerActive) {
if (open && !controllerActive) focusManager.clearFocus(force = true)
}
// The page focus was last put on: turning to another (LB, RB, a tab) starts it at its first
// option; anything else (a menu closing, the drawer opening again) goes back where it was.
var focusedPage by remember { mutableIntStateOf(page) }
var focusedPage by remember { mutableStateOf(page) }
LaunchedEffect(open, page, controllerActive, host.open, appToChooseDisplay, confirmStop) {
if (!open) {
host.open = null
@@ -270,8 +294,8 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
if (host.open != null || appToChooseDisplay != null || confirmStop) return@LaunchedEffect
val turned = page != focusedPage
focusedPage = page
// The page's top control: on Settings that is Android apps, when it is shown.
val first = if (page == drawerPageEntries.lastIndex && a.isHomeApp) "apps" else drawerPageEntries[page]
// The page's top control: on Session that is Android apps, when it is shown.
val first = if (page == SessionDrawerPage.SESSION && a.isHomeApp) "apps" else page.firstControl
if (turned) focus.forget(page)
val target = focus.target(page) ?: first
repeat(24) {
@@ -284,7 +308,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
}
if (open || veil > 0.01f) Box(
modifier = Modifier.fillMaxSize().graphicsLayer { alpha = veil }.background(Color(0x8A000000))
modifier = Modifier.fillMaxSize()
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { host.open = null; a.onClose() },
)
androidx.compose.foundation.layout.BoxWithConstraints(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
@@ -350,31 +374,21 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
modifier = Modifier.fillMaxWidth().padding(top = if (short) 6.dp else 12.dp),
) {
DrawerBumper("LB", stringResource(R.string.drawer_prev_page), modifier = focus.track(page, "prev")) {
host.open = null; onPageChange((page + DRAWER_PAGES - 1) % DRAWER_PAGES)
host.open = null; onPageChange(pages.step(page, -1))
}
DrawerPageTabs(page = page, compact = short, modifier = Modifier.weight(1f)) { index -> host.open = null; onPageChange(index) }
DrawerPageTabs(page = page, pages = pages, compact = short, modifier = Modifier.weight(1f)) { index -> host.open = null; onPageChange(index) }
DrawerBumper("RB", stringResource(R.string.drawer_next_page), modifier = focus.track(page, "next")) {
host.open = null; onPageChange((page + 1) % DRAWER_PAGES)
host.open = null; onPageChange(pages.step(page, 1))
}
}
Column(
modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(pageScroll[page]),
modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(pageScroll.getValue(page)),
) { CompositionLocalProvider(LocalChipMinWidth provides 120.dp) {
when (page) {
0 -> {
SessionDrawerPage.DISPLAY -> {
SettingsGroup(stringResource(R.string.drawer_page_display)) {
ToggleRow(host, "hud", stringResource(R.string.drawer_hud), null, a.hudOn,
chipModifier = focus.track(page, "hud"), onChange = a.onHud)
if (a.fillScreen != null) ToggleRow(
host, "fill", stringResource(R.string.drawer_fill), null, a.fillScreen,
chipModifier = focus.track(page, "fill"), onChange = a.onFillScreen,
)
ChoiceRow(host, "upscaler", stringResource(R.string.drawer_scaling), null,
SessionPrefs.upscalerChoices, a.upscaler,
chipModifier = focus.track(page, "upscaler"), onPick = a.onUpscaler)
SliderRow(stringResource(R.string.drawer_scaling_sharpness), null, a.upscaleSharpness, 0..100, step = 5,
enabled = a.upscaler != 0, format = { "$it%" }, modifier = focus.track(page, "upscale-sharpness"),
onChange = a.onUpscaleSharpness)
val fgOpen = host.open == "fg"
val fgLabel = FrameGen.label(LocalContext.current, a.frameGen)
SettingsRow(stringResource(R.string.frame_gen_title), null, highlighted = fgOpen) {
@@ -384,14 +398,27 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
}
}
SettingsGroup(stringResource(R.string.display_image_scaling)) {
ChoiceRow(host, "upscaler", stringResource(R.string.display_filter), null,
SessionPrefs.upscalerChoices, a.upscaler,
note = stringResource(R.string.display_filter_note),
chipModifier = focus.track(page, "upscaler"), onPick = a.onUpscaler)
if (SessionPrefs.upscalerHasSharpness(a.upscaler)) SliderRow(
stringResource(R.string.display_sharpness), null, a.upscaleSharpness, 0..100, step = 5,
format = { "$it%" }, modifier = focus.track(page, "upscale-sharpness"), onChange = a.onUpscaleSharpness)
}
if (a.fillScreen != null) SettingsGroup(stringResource(R.string.display_window_compatibility)) {
ToggleRow(host, "fill", stringResource(R.string.display_force_fullscreen),
stringResource(R.string.display_force_fullscreen_live), a.fillScreen,
chipModifier = focus.track(page, "fill"), onChange = a.onFillScreen)
}
}
DRAWER_PAGE_EFFECTS -> {
ScreenEffectsGroup(host, a) { key -> focus.track(page, key) }
TextureFilteringGroup(host, a) { key -> focus.track(page, key) }
SessionDrawerPage.EFFECTS -> {
ScreenEffectsGroup(host, a, advancedEffectsExpanded,
onAdvancedToggle = { advancedEffectsExpanded = !advancedEffectsExpanded }) { key -> focus.track(page, key) }
}
DRAWER_PAGE_COMPONENTS -> ComponentsDrawerPage(host, a) { key -> focus.track(page, key) }
DRAWER_PAGE_CONTROLS -> {
SettingsGroup(stringResource(R.string.drawer_page_controls)) {
SessionDrawerPage.CONTROLLER -> {
SettingsGroup(stringResource(R.string.drawer_page_controller)) {
ChoiceRow(host, "touch", stringResource(R.string.mode_touch), null,
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.drawer_touch_auto, a.touchAuto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)),
a.touchMode, chipModifier = focus.track(page, "touch"), onPick = a.onTouch)
@@ -402,12 +429,16 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
a.controller?.let { c ->
ToggleRow(host, "rumble", stringResource(R.string.ctrl_rumble), null, c.rumble,
chipModifier = focus.track(page, "rumble"), onChange = a.onRumble)
ToggleRow(host, "steam-button", stringResource(R.string.ctrl_steam_button), null, c.steamButton,
chipModifier = focus.track(page, "steam-button"), onChange = a.onSteamButton)
ToggleRow(host, "qam-button", stringResource(R.string.ctrl_qam_button), null, c.qamButton,
chipModifier = focus.track(page, "qam-button"), onChange = a.onQamButton)
ToggleRow(host, "keyboard-button", stringResource(R.string.ctrl_keyboard_button), null, c.keyboardButton,
chipModifier = focus.track(page, "keyboard-button"), onChange = a.onKeyboardButton)
if (a.onScreenButtonsVisible) {
if (a.steam) {
ToggleRow(host, "steam-button", stringResource(R.string.ctrl_steam_button), null, c.steamButton,
chipModifier = focus.track(page, "steam-button"), onChange = a.onSteamButton)
ToggleRow(host, "qam-button", stringResource(R.string.ctrl_qam_button), null, c.qamButton,
chipModifier = focus.track(page, "qam-button"), onChange = a.onQamButton)
}
ToggleRow(host, "keyboard-button", stringResource(R.string.ctrl_keyboard_button), null, c.keyboardButton,
chipModifier = focus.track(page, "keyboard-button"), onChange = a.onKeyboardButton)
}
}
if (a.steam) ChoiceRow(host, "back-actions", stringResource(R.string.mode_back), null,
listOf(false to SessionPrefs.BACK_MENU_THEN_QAM, true to SessionPrefs.BACK_QAM_THEN_MENU),
@@ -423,18 +454,23 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
}
}
if (a.steam && a.secondScreenDisplays.isNotEmpty()) SettingsGroup(stringResource(R.string.drawer_second_screen)) {
ChoiceRow(host, "second-screen-mode", stringResource(R.string.drawer_second_screen_shows), null,
(listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL) +
(if (com.droiddeck.launcher.session.SessionState.deckPad) listOf(SecondScreenMode.DECK_CONTROLS) else emptyList()))
.map { it to it.label },
a.secondScreenMode, chipModifier = focus.track(page, "second-screen-mode"), onPick = a.onSecondScreenMode)
if (a.secondScreenDisplays.size > 1) ChoiceRow(host, "second-screen-display", stringResource(R.string.drawer_page_display), null,
a.secondScreenDisplays.map { it.id to it.label }, a.selectedSecondScreenDisplay,
chipModifier = focus.track(page, "second-screen-display"), onPick = a.onSecondScreenDisplay)
}
}
else -> {
SessionDrawerPage.SECOND_SCREEN -> {
SecondScreenGroup(host, a) { key -> focus.track(page, key) }
}
SessionDrawerPage.GAMES -> {
Text(stringResource(R.string.fex_next_launch), fontSize = 12.sp,
color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp))
TextureFilteringGroup(host, a) { key -> focus.track(page, key) }
if (a.steam) SettingsGroup(stringResource(R.string.game_settings_title)) {
ChoiceRow(host, "fex", stringResource(R.string.fex_preset_title), null,
FexPreset.all.map { it.id to stringResource(it.label) }, a.fexPreset,
chipModifier = focus.track(page, "fex"), onPick = a.onFexPreset)
GameEnvironmentRow(modifier = focus.track(page, "game-env"), showHint = false)
}
ComponentsDrawerPage(host, a) { key -> focus.track(page, key) }
}
SessionDrawerPage.SESSION -> {
if (a.isHomeApp) SettingsGroup(stringResource(R.string.drawer_android_apps)) {
SettingsRow(stringResource(R.string.drawer_launch_app), null) {
DrawerOutlineButton(if (androidAppsExpanded) stringResource(R.string.common_hide) else stringResource(R.string.common_show), modifier = focus.track(page, "apps")) {
@@ -466,7 +502,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
SettingsGroup(stringResource(R.string.drawer_session_behavior)) {
ChoiceRow(
host, "suspend", stringResource(R.string.mode_suspend),
stringResource(R.string.drawer_suspend_hint),
null,
listOf(
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
@@ -478,17 +514,6 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
onPick = a.onSuspendPolicy,
)
}
SettingsGroup(stringResource(R.string.drawer_next_session)) {
ChoiceRow(host, "shape", stringResource(R.string.mode_ratio), null,
SessionPrefs.shapeChoices, a.shapeMode,
chipModifier = focus.track(page, "shape"), onPick = a.onShape)
}
if (a.steam) SettingsGroup(stringResource(R.string.game_settings_title)) {
ChoiceRow(host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch),
FexPreset.all.map { it.id to stringResource(it.label) }, a.fexPreset,
chipModifier = focus.track(page, "fex"), onPick = a.onFexPreset)
GameEnvironmentRow(modifier = focus.track(page, "game-env"))
}
SettingsGroup(stringResource(R.string.drawer_support)) {
SettingsRow(stringResource(R.string.drawer_logs), stringResource(R.string.drawer_logs_hint)) {
DrawerOutlineButton(stringResource(R.string.drawer_share_logs), modifier = focus.track(page, "share-logs")) {
@@ -501,12 +526,11 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null,
a.pipAutoEnter, onChange = a.onPipAutoEnter)
}
Spacer(Modifier.height(18.dp))
if (a.pipSupported) DrawerOutlineButton(stringResource(R.string.pip_title), modifier = focus.track(page, "pip")) {
host.open = null; a.onPip()
}
if (!a.isHomeApp) DrawerOutlineButton(stringResource(R.string.drawer_background), modifier = focus.track(page, "background")) {
host.open = null; a.onBackground()
Column(Modifier.fillMaxWidth().padding(top = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (a.pipSupported) DrawerActionButton(stringResource(R.string.pip_title), Icons.Outlined.PictureInPictureAlt,
modifier = focus.track(page, "pip")) { host.open = null; a.onPip() }
if (!a.isHomeApp) DrawerActionButton(stringResource(R.string.drawer_background), Icons.AutoMirrored.Outlined.ExitToApp,
modifier = focus.track(page, "background")) { host.open = null; a.onBackground() }
}
}
}
@@ -546,6 +570,27 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
}
@Composable
private fun DrawerActionButton(text: String, icon: androidx.compose.ui.graphics.vector.ImageVector, modifier: Modifier = Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val source = remember { MutableInteractionSource() }
val hot = source.collectIsFocusedAsState().value || source.collectIsHoveredAsState().value
val shape = RoundedCornerShape(8.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier.fillMaxWidth().heightIn(min = 52.dp).clip(shape)
.background(if (hot) pal.signal.copy(alpha = 0.16f) else colors.surfaceVariant)
.glideBorder(hot, shape, pal.signal)
.hoverable(source).clickable(interactionSource = source, indication = LocalIndication.current, onClick = onClick)
.controllerConfirm(onClick = onClick).padding(horizontal = 16.dp, vertical = 10.dp),
) {
Icon(icon, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(22.dp))
Text(text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
}
}
@Composable
private fun DrawerOutlineButton(text: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
@@ -636,58 +681,60 @@ private fun DrawerBumper(label: String, description: String, modifier: Modifier
/**
* The drawer's page tabs, QAM-style: one icon per page standing in a line in page order. The
* selected one steps forward - full size, bright, a soft glow - while the others step back to
* half size and fade, and the whole line leans a little toward the selection. LB / RB still turn
* a little smaller and fade, and the whole line leans a little toward the selection. LB / RB still turn
* the page (the buttons beside it and the pad's bumpers); a tab can be tapped or picked with the
* pad like the dots it replaces.
*/
@Composable
private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, compact: Boolean = false, onSelect: (Int) -> Unit) {
private fun DrawerPageTabs(page: SessionDrawerPage, pages: List<SessionDrawerPage>, modifier: Modifier = Modifier, compact: Boolean = false, onSelect: (SessionDrawerPage) -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val count = drawerPageIcons.size
val count = pages.size
val selectedIndex = pages.indexOf(page)
val middle = (count - 1) / 2f
val motion = Motion.tw<Float>(340, easing = FastOutSlowInEasing)
androidx.compose.foundation.layout.BoxWithConstraints(
contentAlignment = Alignment.Center,
modifier = modifier.height(if (compact) 44.dp else 64.dp).clipToBounds(),
) {
val tab = if (compact) 40f else 56f
val tab = if (compact) 36f else 44f
// Fit the line to the room between LB and RB. Worst case is an end tab selected: full size
// ([tab] dp) and pushed outward by the lean, which scales with the spacing. So
// (count-1) * spacing * (1 + lean/64) + 56 <= width
// - never more than the mock's 64 dp, never tighter than 32 dp.
val spacing = ((maxWidth.value - tab) / ((count - 1) * (1f + DRAWER_TAB_LEAN_DP / DRAWER_TAB_SPACING_DP))).coerceIn(32f, DRAWER_TAB_SPACING_DP)
// (count-1) * spacing * (1 + lean/spacingMax) + tab <= width
val spacing = ((maxWidth.value - tab) / ((count - 1) * (1f + DRAWER_TAB_LEAN_DP / DRAWER_TAB_SPACING_DP))).coerceIn(24f, DRAWER_TAB_SPACING_DP)
val leanStep = DRAWER_TAB_LEAN_DP * spacing / DRAWER_TAB_SPACING_DP
val lean by animateFloatAsState(-(page - middle) * leanStep, motion, label = "tabLean")
drawerPageIcons.forEachIndexed { index, icon ->
val selected = index == page
val source = remember { MutableInteractionSource() }
val focused = source.collectIsFocusedAsState().value
val scale by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_SCALE, motion, label = "tabScale")
val alpha by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_ALPHA, motion, label = "tabAlpha")
val glow by animateFloatAsState(if (selected) 1f else 0f, motion, label = "tabGlow")
val pageDescription = stringResource(R.string.drawer_page_desc, stringResource(drawerPageTitles[index]))
val tint by animateColorAsState(if (selected) colors.onBackground else colors.onSurfaceVariant, Motion.tw(340, easing = FastOutSlowInEasing), label = "tabTint")
val select = { onSelect(index) }
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.offset(x = ((index - middle) * spacing + lean).dp)
.size(tab.dp)
.graphicsLayer { scaleX = scale; scaleY = scale; this.alpha = alpha }
.clip(RoundedCornerShape(16.dp))
.background(
Brush.radialGradient(
listOf(pal.signal.copy(alpha = 0.34f * glow), pal.signal.copy(alpha = 0.10f * glow), Color.Transparent),
),
)
.glideBorder(focused, RoundedCornerShape(16.dp), colors.onBackground)
.semantics { contentDescription = pageDescription }
.hoverable(source)
.clickable(interactionSource = source, indication = LocalIndication.current, onClick = select)
.controllerConfirm(onClick = select),
) {
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(if (compact) 22.dp else 30.dp))
val lean by animateFloatAsState(-(selectedIndex - middle) * leanStep, motion, label = "tabLean")
pages.forEachIndexed { index, item ->
androidx.compose.runtime.key(item) {
val selected = item == page
val source = remember { MutableInteractionSource() }
val focused = source.collectIsFocusedAsState().value
val scale by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_SCALE, motion, label = "tabScale")
val alpha by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_ALPHA, motion, label = "tabAlpha")
val glow by animateFloatAsState(if (selected) 1f else 0f, motion, label = "tabGlow")
val pageDescription = stringResource(R.string.drawer_page_desc, stringResource(item.title))
val tint by animateColorAsState(if (selected) colors.onBackground else colors.onSurfaceVariant, Motion.tw(340, easing = FastOutSlowInEasing), label = "tabTint")
val select = { onSelect(item) }
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.offset(x = ((index - middle) * spacing + lean).dp)
.size(tab.dp)
.graphicsLayer { scaleX = scale; scaleY = scale; this.alpha = alpha }
.clip(RoundedCornerShape(16.dp))
.background(
Brush.radialGradient(
listOf(pal.signal.copy(alpha = 0.34f * glow), pal.signal.copy(alpha = 0.10f * glow), Color.Transparent),
),
)
.glideBorder(focused, RoundedCornerShape(16.dp), colors.onBackground)
.semantics { contentDescription = pageDescription }
.hoverable(source)
.clickable(interactionSource = source, indication = LocalIndication.current, onClick = select)
.controllerConfirm(onClick = select),
) {
Icon(item.icon, contentDescription = null, tint = tint, modifier = Modifier.size(if (compact) 22.dp else 30.dp))
}
}
}
}
@@ -696,54 +743,114 @@ private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, compact: Bo
/** The Look row's value when the rows below match no Look. */
private const val LOOK_CUSTOM = "custom"
/**
* Screen effects on the Effects page: a Look row that moves every row under it, then the rows
* themselves: switches and sliders the d-pad steps (SliderRow), so a pad drives them.
*/
/** Presets and simple effects first; detailed adjustments stay behind Advanced. */
@Composable
private fun ScreenEffectsGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
private fun ScreenEffectsGroup(
host: MenuHost, a: DrawerActions, advancedExpanded: Boolean, onAdvancedToggle: () -> Unit,
track: (String) -> Modifier,
) {
val e = a.effects
val look = ScreenEffectLooks.match(e, a.upscaler)
val preset = ScreenEffectLooks.match(e, a.upscaler)
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
SettingsGroup(stringResource(R.string.drawer_effects)) {
val looks = ScreenEffectLooks.LOOKS.map { it.name to it.name }
ChoiceRow(host, "look", stringResource(R.string.drawer_look),
look?.desc ?: stringResource(R.string.drawer_effects_hint),
if (look == null) listOf(LOOK_CUSTOM to stringResource(R.string.drawer_look_custom)) + looks else looks,
look?.name ?: LOOK_CUSTOM, chipModifier = track("look"), hintLines = 2) { name ->
val presets = ScreenEffectLooks.LOOKS.map { it.name to it.name }
ChoiceRow(host, "look", stringResource(R.string.drawer_look), null,
if (preset == null) listOf(LOOK_CUSTOM to stringResource(R.string.drawer_look_custom)) + presets else presets,
preset?.name ?: LOOK_CUSTOM, chipModifier = track("look")) { name ->
ScreenEffectLooks.LOOKS.firstOrNull { it.name == name }?.let { picked ->
picked.scalingMode?.let(a.onUpscaler)
a.onEffects(picked.effects)
}
}
ToggleRow(host, "cas", stringResource(R.string.drawer_cas), null, e.cas, chipModifier = track("cas")) { a.onEffects(e.copy(cas = it)) }
SliderRow(stringResource(R.string.drawer_cas_level), null, e.casLevel, 0..100, step = 5, enabled = e.cas,
format = { "$it%" }, modifier = track("cas-level")) { a.onEffects(e.copy(casLevel = it)) }
ToggleRow(host, "fake-hdr", stringResource(R.string.drawer_fake_hdr), null, e.hdr, chipModifier = track("fake-hdr")) { a.onEffects(e.copy(hdr = it)) }
ToggleRow(host, "deband", stringResource(R.string.drawer_deband), null, e.deband, chipModifier = track("deband")) { a.onEffects(e.copy(deband = it)) }
SliderRow(stringResource(R.string.drawer_deband_strength), null, e.debandStrength, 0..200, step = 5, enabled = e.deband,
format = { "$it%" }, modifier = track("deband-strength")) { a.onEffects(e.copy(debandStrength = it)) }
SliderRow(stringResource(R.string.drawer_brightness), null, e.brightness, -100..100, step = 2,
format = ::signed, modifier = track("brightness")) { a.onEffects(e.copy(brightness = it)) }
SliderRow(stringResource(R.string.drawer_contrast), null, e.contrast, -100..100, step = 2,
format = ::signed, modifier = track("contrast")) { a.onEffects(e.copy(contrast = it)) }
SliderRow(stringResource(R.string.drawer_gamma), null, (e.gamma * 100f).roundToInt(), 50..300, step = 5,
format = { String.format(Locale.US, "%.2f", it / 100f) }, modifier = track("gamma")) { a.onEffects(e.copy(gamma = it / 100f)) }
SliderRow(stringResource(R.string.drawer_saturation), null, e.saturation, 0..200, step = 5,
format = { "$it%" }, modifier = track("saturation")) { a.onEffects(e.copy(saturation = it)) }
ToggleRow(host, "fxaa", stringResource(R.string.drawer_fxaa), null, e.fxaa, chipModifier = track("fxaa")) { a.onEffects(e.copy(fxaa = it)) }
ToggleRow(host, "toon", stringResource(R.string.drawer_toon), null, e.toon, chipModifier = track("toon")) { a.onEffects(e.copy(toon = it)) }
ToggleRow(host, "crt", stringResource(R.string.drawer_crt), null, e.crt, chipModifier = track("crt")) { a.onEffects(e.copy(crt = it)) }
ToggleRow(host, "ntsc", stringResource(R.string.drawer_ntsc), null, e.ntsc, chipModifier = track("ntsc")) { a.onEffects(e.copy(ntsc = it)) }
ToggleRow(host, "fake-hdr", stringResource(R.string.drawer_fake_hdr), null, e.hdr, chipModifier = track("fake-hdr")) { a.onEffects(e.copy(hdr = it)) }
}
val source = remember { MutableInteractionSource() }
val hot = source.collectIsFocusedAsState().value || source.collectIsHoveredAsState().value
val rotation by animateFloatAsState(if (advancedExpanded) 180f else 0f, Motion.tw(180), label = "advancedArrow")
val description = stringResource(if (advancedExpanded) R.string.widgets_expanded else R.string.widgets_collapsed)
val toggle = { host.open = null; onAdvancedToggle() }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = track("effects-advanced").fillMaxWidth().padding(top = 12.dp).heightIn(min = 48.dp)
.glideBorder(hot, RoundedCornerShape(8.dp), pal.signal)
.hoverable(source).clickable(interactionSource = source, indication = LocalIndication.current, role = Role.Button, onClick = toggle)
.controllerConfirm(onClick = toggle).semantics { stateDescription = description }.padding(horizontal = 4.dp),
) {
Text(stringResource(R.string.drawer_effects_advanced).uppercase(), fontSize = 12.sp,
fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant)
Box(Modifier.weight(1f).height(1.dp).background(pal.line))
Icon(Icons.Outlined.ExpandMore, contentDescription = null, tint = colors.onSurfaceVariant,
modifier = Modifier.size(22.dp).rotate(rotation))
}
AnimatedVisibility(advancedExpanded) {
Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(colors.surface).border(1.dp, pal.line, RoundedCornerShape(14.dp))) {
ToggleRow(host, "cas", stringResource(R.string.drawer_cas), null, e.cas, chipModifier = track("cas")) { a.onEffects(e.copy(cas = it)) }
if (e.cas) SliderRow(stringResource(R.string.drawer_cas_level), null, e.casLevel, 0..100, step = 5,
format = { "$it%" }, modifier = track("cas-level")) { a.onEffects(e.copy(casLevel = it)) }
ToggleRow(host, "deband", stringResource(R.string.drawer_deband), null, e.deband, chipModifier = track("deband")) { a.onEffects(e.copy(deband = it)) }
if (e.deband) SliderRow(stringResource(R.string.drawer_deband_strength), null, e.debandStrength, 0..200, step = 5,
format = { "$it%" }, modifier = track("deband-strength")) { a.onEffects(e.copy(debandStrength = it)) }
SliderRow(stringResource(R.string.drawer_brightness), null, e.brightness, -100..100, step = 2,
format = ::signed, modifier = track("brightness")) { a.onEffects(e.copy(brightness = it)) }
SliderRow(stringResource(R.string.drawer_contrast), null, e.contrast, -100..100, step = 2,
format = ::signed, modifier = track("contrast")) { a.onEffects(e.copy(contrast = it)) }
SliderRow(stringResource(R.string.drawer_gamma), null, (e.gamma * 100f).roundToInt(), 50..300, step = 5,
format = { String.format(Locale.US, "%.2f", it / 100f) }, modifier = track("gamma")) { a.onEffects(e.copy(gamma = it / 100f)) }
SliderRow(stringResource(R.string.drawer_saturation), null, e.saturation, 0..200, step = 5,
format = { "$it%" }, modifier = track("saturation")) { a.onEffects(e.copy(saturation = it)) }
}
}
}
/** Texture filtering on the Effects page: DXVK options for the next DirectX 9-11 launch. */
/** Secondary-display modes are mutually exclusive and apply immediately. */
@Composable
private fun SecondScreenGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val modes = listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL) +
if (com.droiddeck.launcher.session.SessionState.deckPad) listOf(SecondScreenMode.DECK_CONTROLS) else emptyList()
SettingsGroup(stringResource(R.string.drawer_second_screen_mode)) {
Column(Modifier.fillMaxWidth().selectableGroup()) {
modes.forEach { mode ->
val selected = a.secondScreenMode == mode
val source = remember { MutableInteractionSource() }
val hot = source.collectIsFocusedAsState().value || source.collectIsHoveredAsState().value
val pick = { host.open = null; a.onSecondScreenMode(mode) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = track("second-screen:${mode.id}").fillMaxWidth().heightIn(min = 56.dp)
.background(if (hot) pal.signal.copy(alpha = 0.12f) else Color.Transparent)
.glideBorder(hot, RoundedCornerShape(8.dp), pal.signal)
.hoverable(source)
.selectable(selected, interactionSource = source, indication = null, role = Role.RadioButton, onClick = pick)
.controllerConfirm(onClick = pick)
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Text(mode.label, fontSize = 15.sp, color = colors.onBackground, modifier = Modifier.weight(1f))
RadioButton(selected, onClick = null,
colors = RadioButtonDefaults.colors(selectedColor = pal.signal, unselectedColor = colors.onSurfaceVariant))
}
}
}
if (a.secondScreenDisplays.size > 1) ChoiceRow(host, "second-screen-display", stringResource(R.string.drawer_page_display), null,
a.secondScreenDisplays.map { it.id to it.label }, a.selectedSecondScreenDisplay,
chipModifier = track("second-screen-display"), onPick = a.onSecondScreenDisplay)
}
}
/** Texture filtering on the Games page: DXVK options for the next DirectX 9-11 launch. */
@Composable
private fun TextureFilteringGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
SettingsGroup(stringResource(R.string.drawer_texture)) {
ChoiceRow(host, "anisotropy", stringResource(R.string.drawer_anisotropy), stringResource(R.string.drawer_texture_hint),
ChoiceRow(host, "anisotropy", stringResource(R.string.drawer_anisotropy), null,
SessionPrefs.textureAnisotropyChoices, a.textureAnisotropy, chipModifier = track("anisotropy"), onPick = a.onTextureAnisotropy)
ChoiceRow(host, "texture-sharpness", stringResource(R.string.drawer_texture_sharpness), stringResource(R.string.drawer_texture_hint),
ChoiceRow(host, "texture-sharpness", stringResource(R.string.drawer_texture_sharpness), null,
SessionPrefs.textureLodBiasChoices, a.textureLodBias, note = stringResource(R.string.drawer_texture_sharpness_note),
chipModifier = track("texture-sharpness"), onPick = a.onTextureLodBias)
}
@@ -751,16 +858,8 @@ private fun TextureFilteringGroup(host: MenuHost, a: DrawerActions, track: (Stri
private fun signed(v: Int) = if (v > 0) "+$v" else "$v"
/** Display, Effects, Controls, Components, Settings. */
const val DRAWER_PAGES = 5
private const val DRAWER_PAGE_EFFECTS = 1
private const val DRAWER_PAGE_CONTROLS = 2
private const val DRAWER_PAGE_COMPONENTS = 3
/**
* The drawer's Components tab: quick swaps between what is already installed, per Proton. A swap
* The Games tab's components: quick swaps between what is already installed, per Proton. A swap
* into the Proton a running game uses waits until that game closes; downloading, importing and
* deleting stay on the Components page in the app.
*/
@@ -884,14 +983,14 @@ private fun <T> DrawerStackedChoice(
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
/** The tab row's measure, from the approved mock: side tabs at half size, a little faded. */
private const val DRAWER_TAB_SPACING_DP = 64f
/** A close row of icons: inactive tabs stay legible while the active tab leads. */
private const val DRAWER_TAB_SPACING_DP = 48f
private const val DRAWER_TAB_LEAN_DP = 12f
private const val DRAWER_TAB_LEAN_DP = 8f
private const val DRAWER_TAB_SIDE_SCALE = 0.5f
private const val DRAWER_TAB_SIDE_SCALE = 0.8f
private const val DRAWER_TAB_SIDE_ALPHA = 0.7f
private const val DRAWER_TAB_SIDE_ALPHA = 0.8f
/** Room between the Stop pill and the box that steps out under it: the pull. */
private val StepPull = 6.dp
@@ -29,6 +29,7 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.semantics.semantics
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -60,6 +61,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -82,6 +84,10 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.semantics.progressBarRangeInfo
import androidx.compose.ui.semantics.setProgress
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.text.style.TextAlign
import kotlin.math.roundToInt
import androidx.compose.ui.input.InputMode
@@ -468,8 +474,16 @@ fun ToggleSwitch(checked: Boolean, enabled: Boolean = true, label: String? = nul
fun SliderRow(
label: String, hint: String?, value: Int, range: IntRange, step: Int = 1, enabled: Boolean = true,
format: (Int) -> String = { it.toString() }, modifier: Modifier = Modifier, onChange: (Int) -> Unit,
) = SettingsRow(label, hint) {
ValueSlider(value, range, step, enabled, label, format, modifier, onChange)
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Column(Modifier.fillMaxWidth()) {
ValueSlider(value, range, step, enabled, label, format,
modifier = modifier.padding(horizontal = 6.dp, vertical = 3.dp), onChange = onChange)
if (hint != null) Text(hint, fontSize = 12.5.sp, color = colors.onSurfaceVariant,
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 8.dp))
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
}
@Composable
@@ -479,34 +493,39 @@ fun ValueSlider(
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val source = remember { MutableInteractionSource() }
val hot = source.collectIsFocusedAsState().value || source.collectIsHoveredAsState().value
val currentOnChange by rememberUpdatedState(onChange)
val span = (range.last - range.first).coerceAtLeast(1)
val fraction = ((value - range.first).toFloat() / span).coerceIn(0f, 1f)
val shape = RoundedCornerShape(99.dp)
val thumbInset = with(LocalDensity.current) { 8.dp.toPx() }
var trackPx by remember { mutableIntStateOf(0) }
val text = format(value)
// A step from the d-pad lands on the grid of [step] from the range's start; a Look's own
// value, which need not be on it, is kept until the user moves.
val shape = RoundedCornerShape(8.dp)
// Preserve off-grid preset values until the user adjusts; every input snaps to the same step.
fun stepped(v: Int) = (((v - range.first).toFloat() / step).roundToInt() * step + range.first).coerceIn(range.first, range.last)
fun fromX(x: Float) { if (trackPx > 0) onChange(stepped(range.first + (x / trackPx * span).roundToInt())) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.heightIn(min = 48.dp)
fun fromX(x: Float) {
val width = trackPx - 2 * thumbInset
if (width > 0) {
val position = ((x - thumbInset) / width).coerceIn(0f, 1f)
currentOnChange(stepped(range.first + (position * span).roundToInt()))
}
}
Column(
modifier = modifier.fillMaxWidth().heightIn(min = 72.dp)
.clip(shape).background(if (hot) pal.signal.copy(alpha = 0.12f) else Color.Transparent)
.glideBorder(hot, shape, pal.signal)
.alpha(if (enabled) 1f else 0.5f)
.hoverable(src)
.focusable(enabled, src)
.hoverable(source).focusable(enabled, source)
.onPreviewKeyEvent { event ->
val keyEvent = event.nativeKeyEvent
val left = keyEvent.keyCode == KeyEvent.KEYCODE_DPAD_LEFT
if (!left && keyEvent.keyCode != KeyEvent.KEYCODE_DPAD_RIGHT) {
false
} else {
// Every down counts, repeats included, so a held direction keeps stepping.
if (!left && keyEvent.keyCode != KeyEvent.KEYCODE_DPAD_RIGHT) false
else {
// Repeated key-down events let a held direction keep adjusting.
if (enabled && keyEvent.action == KeyEvent.ACTION_DOWN) {
val next = stepped(value + if (left) -step else step)
if (next != value) onChange(next)
if (next != value) currentOnChange(next)
}
true
}
@@ -515,28 +534,33 @@ fun ValueSlider(
stateDescription = text
progressBarRangeInfo = ProgressBarRangeInfo(value.toFloat(), range.first.toFloat()..range.last.toFloat())
if (label != null) contentDescription = label
},
if (!enabled) disabled()
setProgress { next ->
if (!enabled) false else { currentOnChange(stepped(next.roundToInt())); true }
}
}
.padding(horizontal = 8.dp, vertical = 6.dp),
) {
Text(text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, textAlign = TextAlign.End,
maxLines = 1, modifier = Modifier.width(56.dp))
Spacer(Modifier.width(10.dp))
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.size(width = 164.dp, height = 30.dp).clip(shape).background(colors.surfaceVariant)
.glideBorder(hot, shape, colors.onBackground, pal.line2)
.onSizeChanged { trackPx = it.width }
.pointerInput(enabled, range, step) { if (enabled) detectTapGestures { fromX(it.x) } }
.pointerInput(enabled, range, step) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
if (label != null) Text(label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold,
color = colors.onBackground, modifier = Modifier.weight(1f))
else Spacer(Modifier.weight(1f))
Text(text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1)
}
Canvas(
Modifier.fillMaxWidth().height(40.dp).onSizeChanged { trackPx = it.width }
.pointerInput(enabled, range, step, thumbInset) { if (enabled) detectTapGestures { fromX(it.x) } }
.pointerInput(enabled, range, step, thumbInset) {
if (enabled) detectHorizontalDragGestures { change, _ -> fromX(change.position.x); change.consume() }
},
) {
Box(Modifier.fillMaxWidth(fraction).fillMaxHeight().background(pal.signal))
Box(
Modifier.padding(4.dp).size(22.dp)
.graphicsLayer { translationX = fraction * (trackPx - 30.dp.toPx()).coerceAtLeast(0f) }
.clip(CircleShape)
.background(pal.onSignal),
)
val start = Offset(thumbInset, size.height / 2f)
val end = Offset(size.width - thumbInset, start.y)
val thumb = Offset(start.x + (end.x - start.x) * fraction, start.y)
val fill = if (hot) pal.signal else colors.onBackground
drawLine(colors.onSurfaceVariant.copy(alpha = 0.35f), start, end, strokeWidth = 4.dp.toPx(), cap = StrokeCap.Round)
if (fraction > 0) drawLine(fill, start, thumb, strokeWidth = 4.dp.toPx(), cap = StrokeCap.Round)
drawCircle(fill, radius = 7.dp.toPx(), center = thumb)
}
}
}
@@ -574,7 +598,7 @@ fun ActionRow(label: String, hint: String?, button: String, onClick: () -> Unit)
@Composable
fun SettingsPage(
host: MenuHost,
title: String,
title: String?,
onBack: () -> Unit,
eyebrow: String? = null,
lede: String? = null,
@@ -584,6 +608,9 @@ fun SettingsPage(
scroll: androidx.compose.foundation.ScrollState? = null,
scrollContent: Boolean = true,
compactLayout: Boolean = false,
/** Fixed controls below the title, or beside Back when the title is omitted. */
header: (@Composable () -> Unit)? = null,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
val scrollState = scroll ?: rememberScrollState()
@@ -591,13 +618,24 @@ fun SettingsPage(
val dim by animateFloatAsState(if (host.open != null) 0.6f else 1f, Motion.tw(220), label = "pageDim")
val narrow = LocalNarrowPane.current && !compactLayout
Column(
modifier = Modifier.fillMaxSize().padding(
modifier = modifier.fillMaxSize().padding(
horizontal = if (compactLayout) 14.dp else if (narrow) 16.dp else 22.dp,
vertical = if (compactLayout) 6.dp else if (narrow) 12.dp else 18.dp,
),
verticalArrangement = Arrangement.spacedBy(if (compactLayout) 2.dp else 0.dp),
) {
if (narrow) {
if (title == null) {
Rise(0) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
BackLink(stringResource(R.string.mode_back), compact = true, onClick = onBack)
if (header != null) Box(Modifier.weight(1f)) { header() }
}
}
} else if (narrow) {
// Back and the title share one line: a 4:3 screen has no height to spare.
Rise(0) {
Row(
@@ -656,6 +694,7 @@ fun SettingsPage(
}
}
}
if (header != null && title != null) header()
Rise(3, Modifier.weight(1f).fillMaxWidth()) {
Column(
modifier = Modifier
@@ -5,9 +5,13 @@ import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import com.droiddeck.launcher.core.AppUiPrefs
import com.droiddeck.launcher.session.SessionPrefs
class Palette(
@@ -67,7 +71,7 @@ val LocalPalette = staticCompositionLocalOf { Themes.byId(Themes.GRAPHITE) }
val Palette.onSignal: Color get() = if (signal.luminance() > 0.18f) Color(0xFF03111F) else Color.White
@Composable
fun DroidDeckTheme(theme: String? = null, content: @Composable () -> Unit) {
fun DroidDeckTheme(theme: String? = null, appScale: Int = AppUiPrefs.DEFAULT_SCALE, content: @Composable () -> Unit) {
val id = theme ?: SessionPrefs.theme(LocalContext.current)
val p = Themes.byId(id)
val scheme = darkColorScheme(
@@ -77,5 +81,13 @@ fun DroidDeckTheme(theme: String? = null, content: @Composable () -> Unit) {
surfaceVariant = p.surfaceVariant, onSurfaceVariant = p.onSurfaceVariant,
error = p.error,
)
CompositionLocalProvider(LocalPalette provides p) { MaterialTheme(colorScheme = scheme, content = content) }
val baseDensity = LocalDensity.current
val baseConfiguration = LocalConfiguration.current
val density = remember(baseDensity, appScale) { scaledAppDensity(baseDensity, appScale) }
val configuration = remember(baseConfiguration, appScale) { scaledAppConfiguration(baseConfiguration, appScale) }
CompositionLocalProvider(
LocalPalette provides p,
LocalDensity provides density,
LocalConfiguration provides configuration,
) { MaterialTheme(colorScheme = scheme, content = content) }
}
@@ -115,6 +115,11 @@ public final class WaylandCompositor {
public static void setPointerLockListener(PointerLockListener l) { pointerLockListener = l; }
/** Unhooks {@code l} only if it is still the listener, so a finishing activity never unhooks its successor's. */
public static void clearPointerLockListener(PointerLockListener l) {
if (pointerLockListener == l) pointerLockListener = null;
}
/** Invoked from native (droiddeck_on_pointer_lock). */
@SuppressWarnings("unused")
static void onPointerLock(boolean locked, int x, int y) {
@@ -500,4 +505,11 @@ public final class WaylandCompositor {
if (dx == 0 && dy == 0) return;
nativeSendSceneInput(6, dx * 256, dy * 256);
}
/** As {@link #sendPointerDelta(int, int)}, keeping a captured mouse's fractions of a pixel. */
public static void sendPointerDelta(float dx, float dy) {
int x = Math.round(dx * 256f), y = Math.round(dy * 256f);
if (x == 0 && y == 0) return;
nativeSendSceneInput(6, x, y);
}
}
+34 -1
View File
@@ -291,6 +291,10 @@
<string name="mode_branch_public">Public beta</string>
<string name="mode_branch_deck_beta">Steam Deck public beta</string>
<string name="mode_branch_note">Deck mode always uses Steam Deck public beta.</string>
<string name="mode_steam_repair">Repair Steam client</string>
<string name="mode_steam_repair_hint">Downloads the Steam client again the next time Steam starts. Use it if Steam stays on its loading screen. Games, sign-in and settings are kept.</string>
<string name="mode_steam_repair_queued">The Steam client will be downloaded again the next time Steam starts.</string>
<string name="mode_steam_repair_button">Repair</string>
<string name="mode_added_games">Added games</string>
<string name="mode_added_none">no game folders with a .exe found</string>
<string name="mode_games_folder">Games folder</string>
@@ -470,6 +474,7 @@
<string name="drawer_page_effects">Effects</string>
<string name="drawer_page_controls">Controls</string>
<string name="drawer_page_components">Components</string>
<string name="drawer_page_controller">Controller</string>
<string name="drawer_page_settings">Settings</string>
<string name="drawer_page_desc">%1$s page</string>
<string name="drawer_steam">Steam</string>
@@ -485,8 +490,11 @@
<string name="drawer_scaling_sharpness">Scaling sharpness</string>
<string name="drawer_effects">Screen effects</string>
<string name="drawer_effects_hint">Live. Costs a little GPU time while any effect is on.</string>
<string name="drawer_look">Look</string>
<string name="drawer_look">Presets</string>
<string name="drawer_look_custom">Custom</string>
<string name="drawer_effects_advanced">Advanced</string>
<string name="widgets_expanded">Expanded</string>
<string name="widgets_collapsed">Collapsed</string>
<string name="drawer_cas">Sharpening (CAS)</string>
<string name="drawer_cas_level">Sharpening level</string>
<string name="drawer_fake_hdr">Fake HDR</string>
@@ -510,6 +518,7 @@
<string name="drawer_pc_keyboard">PC keyboard</string>
<string name="drawer_android_keyboard">Android</string>
<string name="drawer_second_screen">Second screen</string>
<string name="drawer_second_screen_mode">Mode</string>
<string name="drawer_second_screen_shows">Shows</string>
<string name="drawer_android_apps">Android apps</string>
<string name="drawer_launch_app">Launch an app</string>
@@ -586,6 +595,9 @@
<string name="setup_offline">Offline mode</string>
<string name="setup_sign_in_first">Sign in to Steam first</string>
<string name="setup_launcher">Launcher</string>
<string name="setup_app_scale">App scale</string>
<string name="setup_app_scale_hint">DroidDeck text and controls</string>
<string name="setup_app_scale_default">%1$d%% (Default)</string>
<string name="setup_theme">Theme</string>
<string name="setup_theme_hint">Launcher appearance</string>
<string name="setup_home">Use as Home screen</string>
@@ -1011,4 +1023,25 @@
<string name="game_file_folder">Choose a folder for frontend game files</string>
<string name="game_file_exported">Game files saved to %1$s</string>
<string name="game_frontend_files">Frontend files</string>
<string name="display_session">Session display</string>
<string name="display_resolution">Resolution</string>
<string name="display_game_fit">16:9 game fit</string>
<string name="display_game_fit_hint">How 16:9 games fit this display. Next session.</string>
<string name="display_game_fit_unavailable">Choose Match screen or a custom size narrower than 16:9.</string>
<string name="display_preserve">Keep proportions</string>
<string name="display_stretch">Stretch to display</string>
<string name="display_image_scaling">Image scaling</string>
<string name="display_filter">Scaling filter</string>
<string name="display_filter_hint">Shared by Steam and Desktop.</string>
<string name="display_filter_live">Applies live. Shared by Steam and Desktop.</string>
<string name="display_filter_note">FSR, GSR and NIS apply when enlarging the session image. Sharpen only also works without enlargement.</string>
<string name="display_sharpness">Filter sharpness</string>
<string name="display_frame_rate">Frame rate</string>
<string name="display_window_compatibility">Game window compatibility</string>
<string name="display_force_fullscreen">Force game windows fullscreen</string>
<string name="display_force_fullscreen_hint">Keeps games fullscreen after focus changes. Turn off if a game draws small. Applies live during a session.</string>
<string name="display_force_fullscreen_live">Keeps games fullscreen after focus changes. Applies live.</string>
<string name="display_look_filter">%1$s Sets scaling to %2$s.</string>
<string name="display_match_screen">Match screen (%1$d×%2$d)</string>
<string name="mode_tab_steam">Steam</string>
</resources>
@@ -0,0 +1,53 @@
package com.droiddeck.launcher.input
import java.io.File
import java.nio.file.Files
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
/**
* The ring paths handed to the guest (FAKE_EVDEV_MEMFD_PATHS) must keep the spelling the session
* tree is bound under. On ColorOS 16 /data/user/0 resolves to /data/data, the guest only has the
* first bound, and a canonical export left every pad dead (#166). A symlinked directory stands in
* for that here.
*/
class FakeInputRingPathTest {
private lateinit var base: File
private lateinit var real: File
private lateinit var link: File
@Before fun setUp() {
base = Files.createTempDirectory("ring-path").toFile()
real = File(base, "data-data").apply { mkdirs() }
link = File(base, "data-user-0")
Files.createSymbolicLink(link.toPath(), real.toPath())
}
@After fun tearDown() {
base.deleteRecursively()
}
@Test fun ringsSitBesideTheFakeInputNodes() {
val fakeInputDir = File(link, "files/session/dev/input")
assertEquals(File(link, "files/session/dev/fakeinput-rings/ring2"), FakeInputRingPaths.ringFile(fakeInputDir, 2))
}
@Test fun theExportKeepsTheBoundSpellingThroughALinkedDataDirectory() {
val fakeInputDir = File(link, "files/session/dev/input").apply { mkdirs() }
val ring = FakeInputRingPaths.ringFile(fakeInputDir, 0)!!.apply { parentFile!!.mkdirs(); writeText("") }
// The link really does resolve elsewhere, as /data/user/0 does on the affected devices.
assertTrue(ring.canonicalPath.startsWith(real.canonicalPath + "/"))
val exported = FakeInputRingPaths.exportPath(ring)
assertEquals(File(link, "files/session/dev/fakeinput-rings/ring0").path, exported)
assertFalse("$exported resolved through the link", exported.startsWith(real.path + "/"))
assertTrue("$exported does not open", File(exported).isFile)
}
@Test fun aRelativeInputDirIsExportedAbsolute() {
val exported = FakeInputRingPaths.exportPath(FakeInputRingPaths.ringFile(File("session/dev/input"), 1)!!)
assertTrue(exported, File(exported).isAbsolute)
assertTrue(exported, exported.endsWith("/session/dev/fakeinput-rings/ring1"))
}
}
@@ -0,0 +1,71 @@
package com.droiddeck.launcher.session
import android.content.Intent
import android.os.BatteryManager
import java.io.File
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class BatteryComponentTest {
@get:Rule val tmp = TemporaryFolder()
private var battery: BatteryComponent? = null
@After fun stop() { battery?.stop() }
@Test fun oneAggregateBatteryKeepsOverlayPowerAndLifetimeConsistentWithQam() {
val supplies = tmp.newFolder("power_supply")
val vpower = tmp.newFolder("vpower")
start(supplies, vpower, -2_000_000L)
assertTelemetry(supplies, vpower)
}
@Test fun upgradingRemovesTheDuplicateRatherThanLeavingItForOverlayDiscovery() {
val supplies = tmp.newFolder("power_supply")
val vpower = tmp.newFolder("vpower")
val legacy = File(supplies, "BAT0").apply { mkdirs() }
File(legacy, "power_now").writeText("8000000\n")
File(legacy, "charge_now").writeText("4000000\n")
start(supplies, vpower, 2_000_000L)
assertFalse(legacy.exists())
assertTelemetry(supplies, vpower)
}
private fun start(supplies: File, vpower: File, currentUa: Long) {
val context = RuntimeEnvironment.getApplication()
context.sendStickyBroadcast(Intent(Intent.ACTION_BATTERY_CHANGED).apply {
putExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_DISCHARGING)
putExtra(BatteryManager.EXTRA_LEVEL, 50)
putExtra(BatteryManager.EXTRA_SCALE, 100)
putExtra(BatteryManager.EXTRA_VOLTAGE, 4000)
})
shadowOf(context.getSystemService(BatteryManager::class.java)).apply {
setLongProperty(BatteryManager.BATTERY_PROPERTY_CHARGE_COUNTER, 4_000_000L)
setLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW, currentUa)
setLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_AVERAGE, currentUa)
}
battery = BatteryComponent(supplies, vpower).also { it.attach(context); it.start() }
}
private fun assertTelemetry(supplies: File, vpower: File) {
val discovered = supplies.listFiles()!!.filter { it.name.contains("BAT") }
assertEquals(listOf("BAT1"), discovered.map { it.name })
fun read(name: String) = File(discovered.single(), name).readText().trim().toLong()
// One 4 Ah battery at 4 V drawing 2 A: 8 W and two hours, regardless of vendor current sign.
assertEquals(8_000_000L, read("power_now"))
assertEquals(16_000_000L, read("energy_now"))
assertEquals(7200L, read("time_to_empty_now"))
assertEquals("7200", File(vpower, "secs_until_shutdown_request").readText().trim())
assertEquals("50.00", File(vpower, "battery_percent").readText().trim())
}
}
@@ -8,6 +8,7 @@ import android.os.Looper
import android.os.Vibrator
import android.os.VibratorManager
import com.droiddeck.launcher.input.ControllerPrefs
import com.droiddeck.launcher.input.PadBridge
import java.io.IOException
import org.junit.After
import org.junit.Before
@@ -68,12 +69,74 @@ class RumbleComponentTest {
assertTrue(shadowOf(vibrator).isVibrating)
}
private fun effect() {
// Inject a decoded force-feedback packet; transport is outside this preference test.
RumbleComponent::class.java.getDeclaredMethod("buzz", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType)
.apply { isAccessible = true }.invoke(rumble, 65535, 5000)
@Test fun aControllerWithMotorsGetsTheEffectInsteadOfThePhone() {
val pad = FakeMotors()
rumble.controllerMotors = { id -> if (id == PAD_ID) pad else null }
activeController(PAD_ID)
effect(strong = 40000, weak = 1000)
assertEquals(listOf(Triple(40000, 1000, 5000L)), pad.played)
assertFalse("the phone buzzed as well", shadowOf(vibrator).isVibrating)
}
@Test fun aControllerWithoutMotorsFallsBackToThePhone() {
rumble.controllerMotors = { null }
activeController(PAD_ID)
effect()
assertTrue(shadowOf(vibrator).isVibrating)
}
@Test fun theOnScreenPadTakingOverMovesTheEffectBackToThePhone() {
val pad = FakeMotors()
rumble.controllerMotors = { pad }
activeController(PAD_ID)
effect()
assertTrue(pad.vibrating)
activeController(PadBridge.NO_CONTROLLER)
effect()
assertFalse("the controller kept rumbling after the switch", pad.vibrating)
assertTrue(shadowOf(vibrator).isVibrating)
}
@Test fun aStopPacketDisablingAndStoppingAllEndTheControllersEffect() {
val pad = FakeMotors()
rumble.controllerMotors = { pad }
activeController(PAD_ID)
effect()
effect(strong = 0, weak = 0)
assertFalse(pad.vibrating)
effect()
ControllerPrefs.setRumble(context, false)
shadowOf(Looper.getMainLooper()).idle()
assertFalse(pad.vibrating)
ControllerPrefs.setRumble(context, true)
shadowOf(Looper.getMainLooper()).idle()
effect()
rumble.stop()
assertFalse(pad.vibrating)
}
@After fun clearActiveController() { activeController(PadBridge.NO_CONTROLLER) }
private fun activeController(id: Int) {
PadBridge::class.java.getDeclaredField("activeControllerId").apply { isAccessible = true }.setInt(null, id)
}
private fun effect(strong: Int = 65535, weak: Int = 65535) {
// Inject a decoded force-feedback packet; transport is outside these tests.
RumbleComponent::class.java.getDeclaredMethod("buzz", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType)
.apply { isAccessible = true }.invoke(rumble, strong, weak, 5000)
}
private class FakeMotors : RumbleComponent.Motors {
override val name = "fake pad"
val played = mutableListOf<Triple<Int, Int, Long>>()
var vibrating = false
override fun play(strong: Int, weak: Int, ms: Long) { played += Triple(strong, weak, ms); vibrating = true }
override fun cancel() { vibrating = false }
}
private companion object { const val PAD_ID = 7 }
@Implements(LocalServerSocket::class)
class NoPacketsSocket {
@Implementation fun __constructor__(name: String) = Unit
@@ -0,0 +1,58 @@
package com.droiddeck.launcher.session
import org.junit.Assert.*
import org.junit.Test
class SessionDisplayTest {
@Test fun explicitPresetsAndMatchScreenResolveWithoutAnyAspectSetting() {
assertEquals(1280 to 720, SessionDisplay.resolveChoice(1440 to 1080, "1280x720"))
assertEquals(1600 to 900, SessionDisplay.resolveChoice(1280 to 720, "1600x900"))
assertEquals(1440 to 1080, SessionDisplay.resolveChoice(1080 to 1440, SessionDisplay.MATCH_SCREEN))
assertEquals(2400 to 1080, SessionDisplay.resolveChoice(1080 to 2400, SessionDisplay.MATCH_SCREEN))
}
@Test fun panelOptionReplacesTheIdenticalPresetAndTracksPanelChanges() {
val thor = SessionDisplay.resolutionOptions(1920 to 1080)
assertEquals(listOf("1280x720", "1600x900", SessionDisplay.MATCH_SCREEN), thor)
val small = SessionDisplay.resolutionOptions(1280 to 720)
assertFalse(small.contains("1280x720"))
assertEquals(1280 to 720, SessionDisplay.resolveChoice(1280 to 720, SessionDisplay.MATCH_SCREEN))
assertEquals(2560 to 1440, SessionDisplay.resolveChoice(2560 to 1440, SessionDisplay.MATCH_SCREEN))
}
@Test fun resolutionCapsPreserveTheSelectedShapeWithoutExceedingPanelHeight() {
assertEquals(1280 to 720, SessionDisplay.resolve(1920 to 1080, 720, SessionPrefs.SHAPE_AUTO))
assertEquals(960 to 720, SessionDisplay.resolve(1440 to 1080, 720, SessionPrefs.SHAPE_EXACT))
assertEquals(1280 to 720, SessionDisplay.resolve(1440 to 1080, 720, SessionPrefs.SHAPE_AUTO))
assertEquals(1280 to 720, SessionDisplay.resolve(1280 to 720, 1080, SessionPrefs.SHAPE_AUTO))
}
@Test fun noCapUsesPanelHeightWhileCustomOverridesBothCapAndAspect() {
assertEquals(1440 to 1080, SessionDisplay.resolve(1440 to 1080, 0, SessionPrefs.SHAPE_EXACT))
assertEquals(1920 to 1080, SessionDisplay.resolve(1440 to 1080, 0, SessionPrefs.SHAPE_WIDE))
assertEquals(1024 to 768, SessionDisplay.resolve(1920 to 1080, 720, SessionPrefs.SHAPE_WIDE, 1024 to 768))
}
@Test fun orientationAndOddDimensionsKeepTheExistingEvenLandscapeDisplay() {
assertEquals(1920 to 1080, SessionDisplay.resolve(1081 to 1921, 0, SessionPrefs.SHAPE_EXACT))
assertEquals(1600 to 720, SessionDisplay.resolve(2400 to 1080, 720, SessionPrefs.SHAPE_AUTO))
}
@Test fun gameStretchAvailabilityMatchesTheRuntimeIncludingRounding() {
assertTrue(SessionDisplay.canStretch16x9(960 to 720))
assertFalse(SessionDisplay.canStretch16x9(1280 to 720))
assertFalse(SessionDisplay.canStretch16x9(1600 to 720))
val wide = SessionDisplay.resolve(1440 to 1080, 0, SessionPrefs.SHAPE_WIDE)
assertFalse(SessionDisplay.canStretch16x9(wide))
}
@Test fun removedFilterAliasesRetainTheirEquivalentBehavior() {
assertEquals(0, SessionPrefs.canonicalUpscaler(1))
assertEquals(4, SessionPrefs.canonicalUpscaler(5))
assertEquals(0, SessionPrefs.canonicalUpscaler(99))
assertFalse(SessionPrefs.upscalerHasSharpness(0))
assertFalse(SessionPrefs.upscalerHasSharpness(1))
assertFalse(SessionPrefs.upscalerHasSharpness(2))
for (mode in 3..8) assertTrue(SessionPrefs.upscalerHasSharpness(mode))
}
}
@@ -0,0 +1,63 @@
package com.droiddeck.launcher.session
import android.content.Context
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class SessionScalingPrefsTest {
@Before fun clearSettings() {
RuntimeEnvironment.getApplication().getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit()
}
@Test fun explicitLegacyAspectAndCapKeepTheirSizeUntilAResolutionIsPicked() {
val context = RuntimeEnvironment.getApplication()
val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE)
prefs.edit().putString("shape", SessionPrefs.SHAPE_EXACT).putInt("resolutionCap.steam", 900).commit()
assertEquals("1200x900", SessionPrefs.resolutionChoice(context, "steam", 1440 to 1080))
SessionPrefs.setResolutionChoice(context, "steam", "1600x900")
prefs.edit().putString("shape", SessionPrefs.SHAPE_WIDE).commit()
assertEquals("1600x900", SessionPrefs.resolutionChoice(context, "steam", 1440 to 1080))
assertTrue(SessionPrefs.resolutionChosen(context, "steam"))
}
@Test fun matchScreenOverridesLegacyCustomAndAspectWithoutAffectingTheOtherMode() {
val context = RuntimeEnvironment.getApplication()
val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE)
prefs.edit().putString("shape", SessionPrefs.SHAPE_WIDE).putString("customRes.steam", "1024x768")
.putString("customRes.lxqt", "800x600").commit()
assertEquals("1024x768", SessionPrefs.resolutionChoice(context, "steam", 1440 to 1080))
SessionPrefs.setResolutionChoice(context, "steam", SessionDisplay.MATCH_SCREEN)
val choice = SessionPrefs.resolutionChoice(context, "steam", 1440 to 1080)
assertEquals(1440 to 1080, SessionDisplay.resolveChoice(1440 to 1080, choice))
assertEquals("800x600", SessionPrefs.resolutionChoice(context, "lxqt", 1440 to 1080))
}
@Test fun freshSettingsUseTheConcreteDefaultAndCustomSizesAreNormalized() {
val context = RuntimeEnvironment.getApplication()
assertEquals("1280x720", SessionPrefs.resolutionChoice(context, "steam", 2400 to 1080))
SessionPrefs.setResolutionChoice(context, "steam", "1025×769")
assertEquals("1024x768", SessionPrefs.resolutionChoice(context, "steam", 2400 to 1080))
}
@Test fun legacySavedFiltersStillResolveToAVisibleEquivalentChoice() {
val context = RuntimeEnvironment.getApplication()
val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE)
prefs.edit().putInt("upscaler", 1).putInt("upscaleSharpness", 65).commit()
assertEquals(0, SessionPrefs.upscaler(context))
prefs.edit().putInt("upscaler", 5).commit()
assertEquals(4, SessionPrefs.upscaler(context))
assertEquals(65, SessionPrefs.upscaleSharpness(context))
SessionPrefs.setUpscaler(context, 5)
assertEquals(4, prefs.getInt("upscaler", -1))
SessionPrefs.setUpscaler(context, 1)
assertEquals(0, prefs.getInt("upscaler", -1))
}
}
@@ -0,0 +1,90 @@
package com.droiddeck.launcher.ui
import android.content.Context
import android.content.res.Configuration
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.core.AppUiPrefs
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class AppScaleTest {
private val context get() = RuntimeEnvironment.getApplication()
@Before fun clearScale() {
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).edit().clear().commit()
}
@Test fun defaultsToSystemDensityAndCanRestoreItAfterChoosingAnotherScale() {
assertEquals(100, AppUiPrefs.scale(context))
AppUiPrefs.setScale(context, 75)
assertEquals(75, AppUiPrefs.scale(context))
AppUiPrefs.setScale(context, 100)
assertEquals(100, AppUiPrefs.scale(context))
val density = Density(2f, 1.3f)
val configuration = Configuration(context.resources.configuration)
assertSame(density, scaledAppDensity(density, 100))
assertSame(configuration, scaledAppConfiguration(configuration, 100))
}
@Test fun invalidStoredOrRequestedScaleFallsBackToTheDefault() {
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).edit().putInt("scale", 0).commit()
assertEquals(100, AppUiPrefs.scale(context))
AppUiPrefs.setScale(context, 1000)
assertEquals(100, AppUiPrefs.scale(context))
}
@Test fun scalingDoesNotModifySessionPreferencesOrAndroidDisplayMetrics() {
val session = context.getSharedPreferences("session", Context.MODE_PRIVATE)
session.edit().putString("resolution.steam", "1280x720").putInt("upscaler", 2).commit()
val before = session.all.toMap()
val density = context.resources.displayMetrics.density
val configuration = Configuration(context.resources.configuration)
AppUiPrefs.setScale(context, 150)
scaledAppConfiguration(context.resources.configuration, AppUiPrefs.scale(context))
assertEquals(before, session.all)
assertEquals(density, context.resources.displayMetrics.density, 0f)
assertEquals(configuration, context.resources.configuration)
}
@Test fun textAndControlsScaleTogetherWhilePreservingAccessibilityFontScale() {
val base = Density(2f, 1.3f)
for (percent in AppUiPrefs.scales) {
val scaled = scaledAppDensity(base, percent)
assertEquals(1.3f, scaled.fontScale, 0f)
assertEquals(base.run { 44.dp.toPx() } * percent / 100f, scaled.run { 44.dp.toPx() }, 0.001f)
assertEquals(base.run { 16.sp.toPx() } * percent / 100f, scaled.run { 16.sp.toPx() }, 0.001f)
}
}
@Test fun responsiveWidthAndPopupHeightFitTheSamePhysicalWindowAtEveryScale() {
val base = Configuration().apply {
densityDpi = 320
screenWidthDp = 960
screenHeightDp = 540
smallestScreenWidthDp = 540
fontScale = 1.3f
orientation = Configuration.ORIENTATION_LANDSCAPE
}
for (percent in AppUiPrefs.scales) {
val scaled = scaledAppConfiguration(base, percent)
val density = scaledAppDensity(Density(2f, 1.3f), percent).density
assertEquals(1920f, scaled.screenWidthDp * density, 2f)
assertEquals(1080f, scaled.screenHeightDp * density, 2f)
assertEquals(1080f, scaled.smallestScreenWidthDp * density, 2f)
assertEquals(1.3f, scaled.fontScale, 0f)
assertEquals(base.orientation, scaled.orientation)
}
assertEquals(960, base.screenWidthDp)
assertEquals(540, base.screenHeightDp)
assertEquals(320, base.densityDpi)
}
}
@@ -0,0 +1,27 @@
package com.droiddeck.launcher.ui
import org.junit.Assert.*
import org.junit.Test
class SessionDrawerPageTest {
@Test fun singleScreenNavigationStartsWithControllerAndSkipsSecondScreen() {
val pages = sessionDrawerPages(false)
assertEquals(SessionDrawerPage.CONTROLLER, pages.first())
assertFalse(SessionDrawerPage.SECOND_SCREEN in pages)
assertEquals(SessionDrawerPage.DISPLAY, pages.step(SessionDrawerPage.CONTROLLER, 1))
assertEquals(SessionDrawerPage.SESSION, pages.step(SessionDrawerPage.CONTROLLER, -1))
assertEquals(SessionDrawerPage.CONTROLLER, pages.step(SessionDrawerPage.SESSION, 1))
}
@Test fun connectedSecondaryDisplayAddsItsPageAfterControllerAndPreservesOtherPages() {
val connected = sessionDrawerPages(true)
assertEquals(SessionDrawerPage.SECOND_SCREEN, connected.step(SessionDrawerPage.CONTROLLER, 1))
assertEquals(SessionDrawerPage.DISPLAY, connected.step(SessionDrawerPage.SECOND_SCREEN, 1))
assertEquals(sessionDrawerPages(false), connected.filter { it != SessionDrawerPage.SECOND_SCREEN })
for (pages in listOf(connected, sessionDrawerPages(false))) {
assertEquals(SessionDrawerPage.EFFECTS, pages.step(SessionDrawerPage.DISPLAY, 1))
assertEquals(SessionDrawerPage.GAMES, pages.step(SessionDrawerPage.EFFECTS, 1))
assertEquals(SessionDrawerPage.SESSION, pages.step(SessionDrawerPage.GAMES, 1))
}
}
}
@@ -0,0 +1,107 @@
Thor SD storage investigation: selected saved evidence
Recorded 2026-10-04; event timestamps are EDT.
These are excerpts, not complete logs or a continuous attribution profile.
See ../thor-sd-storage-investigation.md for context and live-only observations.
=== Reporter unsupported allocation ===
Source: Reporter diagnostic storage.log
volume_removable=true
filesystem=fuse
allocation pid=29062 comm=steam api=fallocate result=-1 api_error=95 errno_after=95 flags=0x0 offset=0 length=374272 elapsed_us=212
=== Thor unsupported allocation ===
Source: candidate-storage.log
volume_removable=true
filesystem=fuse
allocation pid=9810 comm=steam api=fallocate result=-1 api_error=95 errno_after=95 flags=0x0 offset=0 length=650752 elapsed_us=310
=== Native-path allocation diagnostics ===
Source: direct-storage-completed.log / session 64
DroidDeck storage diagnostics v1
selected_library=secondary
volume_removable=true
filesystem=exfat
truncate_allocation_probe=passed
block_size_bytes=131072
total_bytes=393855500288
available_bytes=101076697088
native_observation=enabled
public_symbol_interposition=enabled
direct_syscalls_and_libc_internal_calls=not_observed
pwrite_timing=sampled_1_in_32
per_process_allocation_detail_limit=64
storage_log_limit_bytes=524288
summary pid=26725 comm=python3 allocation_calls=0 allocation_failures=0 allocation_total_us=0 pwrite_sample_every=32 pwrite_samples=0 pwrite_bytes=0 pwrite_failures=0 pwrite_sample_total_us=0 pwrite_sample_max_us=0 ftruncate_calls=0 ftruncate_failures=0 ftruncate_total_us=0 ftruncate_max_us=0 fstat_calls=1 fstat_total_us=0 fstat_max_us=0 fsync_calls=0 fsync_failures=0 fsync_total_us=0 fsync_max_us=0
allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=667648 elapsed_us=387
allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=39 elapsed_us=32
allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=156 elapsed_us=25
allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=457428 elapsed_us=351
allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=901928 elapsed_us=864
allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=15041920 elapsed_us=15292
allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=337563 elapsed_us=2783
=== Fresh real Steam install ===
Source: direct-content-completed.log / session 64
[2026-10-04 17:31:20] AppID 1675830 App update changed : Running Update,Preallocating,
[2026-10-04 17:38:04] AppID 1675830 preallocated 2216 files (7626 MB)
[2026-10-04 17:38:06] AppID 1675830 App update changed : Running Update,Downloading,Staging,
[2026-10-04 17:45:25] AppID 1675830 App update changed : Running Update,Committing,
[2026-10-04 17:45:25] AppID 1675830 starting commit from "/mnt/droiddeck-sd/steamapps/downloading/1675830" to "/mnt/droiddeck-sd/steamapps/common/1000xRESIST" : 2230 updated, 0 moved, 0 deleted files
[2026-10-04 17:47:55] AppID 1675830 state changed : Fully Installed,Update Queued,Update Running,
[2026-10-04 17:47:55] AppID 1675830 finished update, 1 mounted depots (BuildID 21498291) : 1675831 (3663189783241284849),
[2026-10-04 17:47:56] AppID 1675830 state changed : Fully Installed,Update Queued,
[2026-10-04 17:47:56] AppID 1675830 state changed : Fully Installed,
[2026-10-04 17:47:56] AppID 1675830 scheduler finished : removed from schedule (result No Error, state 0xc)
=== Normal-app native/FUSE directory read ===
Source: app-raw-probe-session.log / session 62
APP_RAW_PROBE uid=10195 path=/mnt/media_rw/FF7F-F56A/Android/data/com.droiddeck.launcher/files/steam/steamapps/downloading/1675830-fuse-control/1000xRESIST_Data/StreamingAssets/aa/StandaloneWindows64 seconds=0.002288 calls=2 bytes=154920 read_error=0
APP_RAW_PROBE uid=10195 path=/storage/FF7F-F56A/Android/data/com.droiddeck.launcher/files/steam/steamapps/downloading/1675830-fuse-control/1000xRESIST_Data/StreamingAssets/aa/StandaloneWindows64 seconds=0.501306 calls=112 bytes=154872 read_error=0
=== Binding regression: bind-probe-before.log ===
Source: bind-probe-before.log
BIND_PROBE index=0 pass=1 errno=0 bytes=17 path=/mnt/droiddeck-sd/steamapps/common/SteamLinuxRuntime_4-arm64/.droiddeck-bind-probe
BIND_PROBE index=1 pass=1 errno=0 bytes=17 path=/mnt/droiddeck-sd/steamapps/common/steamlinuxruntime_4-arm64/.droiddeck-bind-probe
BIND_PROBE index=2 pass=0 errno=2 bytes=-1 path=/mnt/droiddeck-sd/steamapps/common/proton experimental (arm64)/.droiddeck-bind-probe
BIND_PROBE index=3 pass=0 errno=2 bytes=-1 path=/mnt/bannerlator-sd/steamapps/shadercache/.droiddeck-bind-probe
=== Binding regression: bind-probe-after.log ===
Source: bind-probe-after.log
BIND_PROBE index=0 pass=1 errno=0 bytes=17 path=/mnt/droiddeck-sd/steamapps/common/SteamLinuxRuntime_4-arm64/.droiddeck-bind-probe
BIND_PROBE index=1 pass=1 errno=0 bytes=17 path=/mnt/droiddeck-sd/steamapps/common/steamlinuxruntime_4-arm64/.droiddeck-bind-probe
BIND_PROBE index=2 pass=1 errno=0 bytes=17 path=/mnt/droiddeck-sd/steamapps/common/proton experimental (arm64)/.droiddeck-bind-probe
BIND_PROBE index=3 pass=1 errno=0 bytes=17 path=/mnt/bannerlator-sd/steamapps/shadercache/.droiddeck-bind-probe
=== Root metadata/mount experiment: metadata-mount-comparison.log ===
Source: metadata-mount-comparison.log
original_mount
/dev/block/vold/public:179,1 /mnt/media_rw/FF7F-F56A exfat rw,dirsync,nosuid,nodev,noexec,noatime,uid=1023,gid=1023,fmask=0007,dmask=0007,allow_utime=0020,iocharset=utf8,errors=remount-ro 0 0
files=64 bytes_per_file=262144 open_seconds=6.707435 truncate_seconds=0.084806 wall_seconds=6.794807 error=0
mount: '/dev/block/vold/public:179,1'->'/mnt/media_rw/FF7F-F56A': Invalid argument
mount: '/dev/block/vold/public:179,1'->'/mnt/media_rw/FF7F-F56A': Invalid argument
=== Root metadata/mount experiment: metadata-syscall-comparison.log ===
Source: metadata-syscall-comparison.log
original_mount
/dev/block/vold/public:179,1 /mnt/media_rw/FF7F-F56A exfat rw,dirsync,nosuid,nodev,noexec,noatime,uid=1023,gid=1023,fmask=0007,dmask=0007,allow_utime=0020,iocharset=utf8,errors=remount-ro 0 0
files=64 bytes_per_file=262144 open_seconds=25.181255 truncate_seconds=0.309577 wall_seconds=25.493210 error=0
temporary_mount
/dev/block/vold/public:179,1 /mnt/media_rw/FF7F-F56A exfat rw,dirsync,nosuid,nodev,noexec,noatime,uid=1023,gid=1023,fmask=0007,dmask=0007,allow_utime=0020,iocharset=utf8,errors=remount-ro 0 0
files=64 bytes_per_file=262144 open_seconds=2.013194 truncate_seconds=0.095924 wall_seconds=2.112288 error=0
=== Post-install Android low-memory exit ===
Source: game-launch-exit-info.txt
ApplicationExitInfo #0:
timestamp=2026-10-04 18:13:48.454 pid=14709 realUid=10195 packageUid=10195 definingUid=10195 user=0
process=com.droiddeck.launcher reason=3 (LOW_MEMORY) subreason=0 (UNKNOWN) status=0
importance=100 pss=227MB rss=344MB description=null state=empty trace=null
=== Combined release unit-test completion ===
Source: combined-233-tests.log
> Task :app:testReleaseUnitTest
BUILD SUCCESSFUL in 10s
=== Combined release build completion ===
Source: combined-233-build.log
BUILD SUCCESSFUL in 25s
APK: /Users/danhimebauch/.codex/worktrees/steam-sd-preallocation/DroidDeck/app/build/outputs/apk/release/app-release.apk
+5
View File
@@ -5,6 +5,11 @@ games should keep running under proot, run directly on glibc, or a mix of the tw
fastest correct setup is. Everything below was measured on a Snapdragon 8 Elite Gen 5 (SM8850, kernel
6.12.38-android16, Android 16) over adb, unless it says otherwise.
For Steam SD allocation measurements on AYN Thor (Android 13), see the
[Thor storage investigation](thor-sd-storage-investigation.md). It separates
observed allocation-wrapper time, FUSE directory access and native exFAT metadata
waits; it does not establish a full-install PRoot overhead percentage.
## Short answer
- **Don't drop proot.** On Android it does two jobs. It translates paths. It also answers every
@@ -0,0 +1,379 @@
# Steam SD allocation on Thor
Investigation recorded on 2026-10-04. Device measurements and PR states below are a
snapshot of that day's work. All event times are EDT (UTC−04:00).
A fresh 1000xRESIST install on the Thor SD card completed with the native-path and
allocation-fallback patches: 2,216 files preallocated in **6m44s**, followed by a
successful download and commit, **16m36s total**. The observed allocation wrappers
reported zero failures and roughly 7.1 seconds of aggregate execution time.
That wrapper measurement does **not** account for the entire preallocation phase.
Native directory access was substantially faster than Android's FUSE view in a
small controlled probe. Kernel samples during the remaining delay showed exFAT
file-creation and size-update waits. This supports further investigation of SD
metadata writes. It does not establish that PRoot is free, that `dirsync` alone
causes the delay, or that SD now matches internal-storage install speed.
Selected source-log excerpts are preserved in
[the evidence file](evidence/thor-sd-storage-2026-10-04.txt). The original full logs
contain unrelated sessions and are not needed to interpret those excerpts.
## Device and storage
| Item | Observed configuration |
|---|---|
| Device | AYN Thor, Android 13, kernel 5.15.123, 12 GB RAM and 8 GB swap |
| App | Production-signed `com.droiddeck.launcher`, normal app UID 10195 |
| Configured SD library | `/storage/FF7F-F56A/Android/data/com.droiddeck.launcher/files/steam` |
| Native view of the same library | `/mnt/media_rw/FF7F-F56A/Android/data/com.droiddeck.launcher/files/steam` |
| Guest content aliases | `/mnt/droiddeck-sd` and legacy `/mnt/bannerlator-sd` |
| SD filesystem | Native exFAT; its Android `/storage` view reports FUSE |
| Native filesystem magic | `0x2011BAB0`, matching the NDK Linux header |
| Internal comparison location | App-private cache under `/data` |
The native SD mount was observed with these options:
```text
rw,dirsync,nosuid,nodev,noexec,noatime,uid=1023,gid=1023,fmask=0007,dmask=0007,allow_utime=0020,iocharset=utf8,errors=remount-ro
```
The device already had root and SELinux Permissive. Neither state was introduced
by this investigation. Root was used for inspection and temporary experiments.
The validated Steam session ran with the app's normal UID and existing groups,
without a root launcher or an app-specific Magisk root grant.
This distinction does not prove the native path works on an unrooted device with
enforcing SELinux. The production change tests access in the app and retains the
configured path when verification fails.
## Initial report and allocation failure
The [linked Discord report](https://discord.com/channels/1552707793247539220/1555632840388116491/1556271955076710560)
included a newer diagnostic ZIP with `storage.log`. Unlike the earlier capture
that lacked allocation instrumentation, this capture established:
- The selected secondary library was removable and its exposed filesystem was
FUSE.
- Steam called mode-zero `fallocate` and received `EOPNOTSUPP` (errno 95).
- That observed failure returned in 212 microseconds. It was not itself a
multi-minute blocked call.
A Thor baseline capture likewise showed `fallocate` failing with errno 95 in
310 microseconds. These establish an unsupported allocation API on the tested
paths. They do not attribute the later Steam delay to any particular fallback.
The suggestion that glibc's `posix_fallocate` emulation accounts for the whole
hang was not established by those logs. The tested Steam calls included
`fallocate`; directory operations and other calls occur outside that wrapper.
## Published patches and the tested build
The main base was `26cb9e2f038cdbab4cdc8670d2f64c18d1d90875`. These heads were
checked again while preparing this report; all three storage PRs were open with
successful Build APK checks and clean merge states. This is a dated snapshot,
not a statement about their future merge status.
| PR | Tested head | Behavior |
|---|---|---|
| [#221](https://github.com/Droid-Deck/DroidDeck/pull/221) | `7962c19283bfb0f18a5c7672e08a879814a3d5a1` | Verify a native SD content alias and handle safe case aliases in the path fast path |
| [#229](https://github.com/Droid-Deck/DroidDeck/pull/229) | `4628ae4c92cc5813013537165a0e95886e6de904` | Use a verified truncate fallback for unsupported selected-library allocation |
| [#233](https://github.com/Droid-Deck/DroidDeck/pull/233) | `5c6765233ae5405d7bd4c1b29f20c9e8343a1fed` | Preserve the complete fast-path binding table, including later private overrides |
| [#225](https://github.com/Droid-Deck/DroidDeck/pull/225) | `0b5908d6d63080f625b51bab3439e2e68466631e` | Repair repeated failed Steam client starts; reviewed separately, excluded from this storage build |
### Native content path and case handling: #221
For a configured `/storage/...` library, the launcher checks the corresponding
`/mnt/media_rw/...` candidate. It requires usable directory permissions and
performs a write/read/delete identity probe through both views. Failure keeps
the original configured path.
Only the shared SD content bindings change. The configured path remains the
library's identity for private prefixes, migrations and other private storage.
This avoids moving existing saves or runtime state when selecting a faster view.
Removable-volume detection also uses the original configured path because
Android's volume API need not recognize the native alias.
The C fast path permits case-only canonical-parent differences on known
FUSE/FAT/exFAT filesystems only after checking directory components without
following symlinks. It also handles the supported `F_OK` plus
`AT_SYMLINK_NOFOLLOW` case. Ambiguous paths still fall back to PRoot.
The final #221 diff was four files, 98 insertions and 20 deletions, including
28 new test lines. The experimental directory cache was removed from that diff.
### Allocation fallback: #229
The launcher first creates a temporary 1 MiB file on the selected content path,
extends it, and checks that `st_blocks` reports at least 2,048 512-byte blocks.
The file must also be cleaned up successfully. The fallback is enabled only for
a removable library that passes this reservation probe.
For file descriptors on the selected library device, the wrapper tries the real
allocation API. On mode-zero `fallocate` returning `EOPNOTSUPP`, it extends the
file with `ftruncate`. It validates the range, detects overflow and avoids
shrinking an existing file. Other modes and other errors retain their normal
behavior. The POSIX wrapper tries kernel allocation first to avoid unnecessary
libc emulation and preserves POSIX error-return and `errno` semantics.
This is a capability-tested fallback, not unconditional successful allocation
reporting. A failed extension still returns an error. The optimization operates
independently of the optional diagnostic toggle.
The reservation probe and one successful full install do not replace separate
future tests for ENOSPC, interrupted installs or other devices/filesystems.
### Complete binding table: #233
The real Steam session had **96 bindings**. The old C limit of 64 silently
dropped later mappings. Several of those were private overrides inside the
shared SD root, so truncation could send an operation to the wrong location or
return `ENOENT`.
#233 raises capacity to 256 and makes both the launcher and C implementation
disable the fast path if a complete table cannot be represented. Overlong paths
also cause fallback rather than partial mapping. This is a correctness fix as
well as a requirement for trusting the storage fast path.
Marker files existed only in private directories. A normal app-process probe
read them through these guest paths:
| Guest path under the library | Before #233 | After #233 |
|---|---|---|
| `SteamLinuxRuntime_4-arm64` | Correct private marker | Correct private marker |
| `steamlinuxruntime_4-arm64` | Correct private marker | Correct private marker |
| `proton experimental (arm64)` | `ENOENT` | Correct private marker |
| Legacy `steamapps/shadercache` | `ENOENT` | Correct private marker |
Each successful read returned the 17-byte `private-override\n` marker. The
temporary markers and helper executable were removed afterward.
### Deployment and checks
The full fresh install used #221 plus #229. #233 was then integrated locally,
tested, built and installed for the binding regression and game-launch check.
It was not merged on GitHub during this investigation.
The combined integration commit was
`f5d3e93b8eb8912ca3834f7447a6498c95250177`. The production-signed APK was installed
in place with `adb install -r`; its SHA-256 was:
```text
099a0d7e76b9462b3b238c6f1abeb03570ec9708ace3092adf178ec2bee63e2a
```
`tools/build_local.sh` completed for the combined build. The release unit tests
for `SecondaryLibraryTest` and `ProotFastPathTest` passed. The latter covers
96/256 bindings, exceeding capacity, and overlong paths. #225 was reviewed for
scope; its tests were not run as part of this storage validation.
## Fresh real Steam install
Game: **1000xRESIST**, app 1675830, depot 1675831, BuildID 21498291. Steam reported
6,582,351,824 download bytes and 7,997,085,707 staging bytes, approximately 8 GB.
| Event | Time | Evidence |
|---|---|---|
| Fresh preallocation began | 17:31:20 | Steam entered `Preallocating` |
| Preallocation completed | 17:38:04 | 2,216 files, 7,626 MB in Steam's log |
| Download/staging began | 17:38:06 | `Downloading,Staging` |
| Commit began | 17:45:25 | 2,230 updated files |
| Fully installed | 17:47:55 | `Fully Installed`, BuildID 21498291 |
| Scheduler completed | 17:47:56 | `No Error` |
Preallocation took **404 seconds**. Start to scheduler completion took
**996 seconds**. Live counters showed 2,216 allocation calls, zero allocation
failures and about **7.1 seconds aggregate allocation-wrapper time**. The saved
diagnostic detail log is capped at the first 64 allocation details per process;
that file alone cannot reproduce the live aggregate counter observation.
The final manifest had `StateFlags=4`, `UpdateResult=0`, all expected download
and staging bytes completed, and `SizeOnDisk=7997085707`. The completed executable
was under `steamapps/common/1000xRESIST`; the active staging directory was gone.
Transfer rates were mostly 117–132 Mbps, about 15–16.5 MB/s. Two root metadata
experiments ran during downloading, so this is not a clean network/card
throughput benchmark. No matched full internal-storage install was performed.
The run's `storage.log` reported native exFAT and a passed truncate reservation
probe. Recorded allocation details used `api=fallocate_ftruncate`, with
`result=0` and `api_error=0`.
## What the metadata measurements show
Metadata is filesystem bookkeeping: names, directory entries, sizes and space
allocation. Steam's `Preallocating` phase includes work outside the observed
allocation wrappers, including creating and locating files.
### Directory reads through native and FUSE paths
A helper ran in a normal app process and listed the same stable asset directory
from a saved staging tree through both host paths. It used the existing fast-path
syscall trampoline to bypass PRoot path translation while retaining the app's
Android permissions. The read buffer was 128 KiB.
| View | Elapsed | Successful `getdents64` calls | Directory bytes |
|---|---|---|---|
| Native exFAT | About 2.3 ms | 2 | 154,920 |
| Android FUSE | About 501 ms | 112 | 154,872 |
The views returned slightly different byte counts. This was a single
native-first pass, not a repeated benchmark with fully controlled cache state.
It demonstrates a large cost difference for that directory read, not a
corresponding multiplier for a full Steam install.
An earlier root listing during concurrent file creation measured roughly
5.56 seconds through FUSE and 9.25 milliseconds native. Its directory was
changing, so it is weaker evidence and is not used as a controlled comparison.
### File creation and size updates
During the fresh native-path Steam run, sampled kernel stacks included:
```text
__sync_dirty_buffer -> exfat_update_bh -> exfat_init_ext_entry
-> exfat_add_entry -> exfat_create -> path_openat
__wait_on_buffer -> exfat_update_bhs -> exfat_free_dentry_set
-> __exfat_write_inode -> exfat_truncate -> do_sys_ftruncate
```
These were live samples, not a continuous attribution profile. They show time
waiting in the native filesystem's creation/update paths during the delay.
A root-only helper created a temporary directory, then 64 long-named files using
`open(O_CREAT|O_EXCL|O_WRONLY, 0600)`, `ftruncate(fd, 262144)` and `close`. It timed
the creation and truncate calls separately and removed its files afterward.
| Location/run | Sum of open times | Sum of truncate times | Whole loop |
|---|---|---|---|
| Native SD, original mount | 6.707435 s | 0.084806 s | 6.794807 s |
| Native SD, later repeat | 25.181255 s | 0.309577 s | 25.493210 s |
| Internal app cache, live observation | About 7.88 ms | About 0.498 ms | About 9.1 ms |
The SD results varied substantially, and these root probes do not reproduce the
normal app's complete Steam workload. The measured internal probe is not a full
internal-install benchmark. Within these probes, file creation cost far more
than the observed truncate calls.
### `dirsync` and the failed remount comparison
The [Linux mount documentation](https://man7.org/linux/man-pages/man2/mount.2.html)
defines `MS_DIRSYNC` as synchronous directory changes. Combined with the observed
exFAT buffer waits, this makes synchronous SD metadata updates a strong lead.
It remains an inference about the remaining overall delay.
A toybox remount attempt returned `Invalid argument`. A direct mount-syscall
experiment changed the root view, but Steam's observed mount table still showed
`dirsync`. A subsequent 64-file probe took 2.112288 seconds; because the effective
flag change was not established and timings were variable, this is **not proof
that removing `dirsync` improves Steam allocation**. The Linux documentation also
notes that attempts to change `MS_DIRSYNC` during a remount are silently ignored.
Original effective options were restored and checked in root and app/process
views. No remount or root-launch behavior is included in the production patches.
### What remains attributable to PRoot
One sampled Steam worker was executing an `openat` through the fast-path
trampoline at `0xffff00020`, with an absolute native host path. That establishes
that some operations bypassed PRoot's path translation in the actual session.
Other syscalls still use PRoot.
There was no matched PRoot-versus-direct full preallocation comparison. The
fraction of the 404 seconds attributable to PRoot is therefore unknown. The
earlier [PRoot performance notes](proot-performance.md) concern different hardware
and workloads and cannot supply that missing percentage.
Seven seconds in public allocation wrappers does not mean the remaining
397 seconds are unrelated to allocation: libc-internal/direct-syscall bypasses,
explicit size changes and file-creation work may occur outside those counters.
## Experiments that did not become the fix
- **Directory caching:** eager scans held up directory open; creation-driven
invalidation repeatedly reread growing directories. The capped design could
also read and discard a large directory before rereading it. A lazy variant
still left 182 files taking 4m49s while the allocation wrappers accounted for
only 0.879 seconds. These were interrupted/resumed runs, not a matched A/B
benchmark. The final #221 snapshot removes the cache entirely.
- **Allocation fallback alone:** a FUSE-path attempt was still slow after
unsupported allocation calls were handled. A later eager-cache resumed run
accumulated roughly 8.36 seconds in 1,435 successful allocation calls over
about 36 minutes of wall time. Successful wrappers alone were insufficient.
- **Launching Steam through root:** experimental wrappers failed compositor
startup. They did not produce allocation measurements and were reverted.
- **Changing mount flags:** effective removal of `dirsync` from Steam's view was
not demonstrated. The apparent probe speedup is not a validated fix.
- **Replacing PRoot or reformatting storage:** no such replacement, adoptable
storage conversion or ext4 loop-image deployment was validated here.
The early FUSE runs used partial staging state. Their file counts and durations
must not be compared to the fresh 2,216-file run as a precise speedup ratio.
## Post-install launch and separate memory failure
After deploying #221 + #229 + #233, the installed SD game was launched through
Steam. First-run Proton prefix initialization preceded the game window.
At **18:10:45**, the compositor changed from Steam Big Picture to **1000xRESIST**
and received the game's 1280×720 GPU frames. A screenshot showed the game loading
screen and spinner. A main menu or sustained gameplay was not validated.
At **18:13:48.454**, Android recorded the foreground app's exit as reason 3,
`LOW_MEMORY`, with main-app PSS 227 MB and RSS 344 MB. Those figures describe that
process, not the complete native process tree or its peak memory use. Subsequent
gamescope/reaper shutdown messages followed the parent being killed.
This was after the install had completed. It is a separate unresolved launch/
memory issue and does not undo the successful allocation/download result.
Post-kill free-memory readings cannot establish peak memory pressure.
## Remaining questions and next useful tests
1. Run the same fresh game install on internal and secondary storage with the
same manifest, awake device and no competing benchmarks. Compare preallocation,
download/staging and commit separately.
2. Match the file-creation/extension workload through PRoot and through the normal
app's direct syscall path. Keep UID, filesystem, names and sizes identical,
repeat runs and record background I/O. This can quantify the remaining tracer
contribution without changing storage semantics.
3. Establish an actual controlled difference in synchronous directory behavior
before attributing a speedup to mount flags. A remount return code alone is
insufficient; this is separate from a portable app patch.
4. Validate native-path fallback under enforcing SELinux and on unrooted devices,
plus allocation behavior near ENOSPC and after interruption. Preserve the
configured-path fallback if either access or reservation verification fails.
5. Investigate the low-memory exit with process-tree/cgroup memory and Android
kill evidence during loading. Treat it separately from install performance.
At this point, one fresh SD install and the binding regression are validated.
Internal-speed parity, the exact PRoot share, a `dirsync` remedy, broad device
coverage and stable gameplay remain unproven.
## Evidence inventory and handoff state
The device captures used for the final measurements were:
- `2026-10-04-62-app-raw-probe`: normal-app native/FUSE directory probe.
- `2026-10-04-64-steam`: fresh complete allocation/download/commit.
- Probe sessions before and after #233: private binding markers.
- `2026-10-04-67-steam`: combined-build launch, followed by Android's low-memory
kill; the exit reason was obtained separately with `dumpsys activity exit-info`.
Local originals during the investigation were under
`/tmp/droiddeck-storage-live/`: `direct-content-completed.log`,
`direct-storage-completed.log`, `app-raw-probe-session.log`,
`bind-probe-before.log`, `bind-probe-after.log`,
`metadata-mount-comparison.log`, `metadata-syscall-comparison.log`,
`game-launch-exit-info.txt`, `combined-233-build.log` and
`combined-233-tests.log`. The repository evidence file preserves the selected
excerpts even if those temporary local files disappear. Live-only counters,
kernel samples and the internal metadata timing are identified above rather
than represented as saved continuous traces.
The combined signed build and completed SD install remained on Thor. Storage
diagnostics remained enabled. Temporary probe files, executables and obsolete
partial staging were removed after validation. Original effective mount options
were restored; no app-specific Magisk root policy remained. The primary checkout's
unrelated `SessionService.kt` modification was preserved.
+62
View File
@@ -0,0 +1,62 @@
# DroidDeck 0.3.1
### Steam
- Decky plugins can be imported from ZIP files, with remote binaries verified and installed copies of the same plugin replaced. (@xXJSONDeruloXx, [#79](https://github.com/Droid-Deck/DroidDeck/pull/79))
- Steam's Internet page shows Android Wi-Fi and cellular connections. Optional Wi-Fi discovery adds network names and nearby networks with Android's Location permission. (@xXJSONDeruloXx, [#150](https://github.com/Droid-Deck/DroidDeck/pull/150))
- Paste text from the Android clipboard into Steam sessions. (@xXJSONDeruloXx, [#151](https://github.com/Droid-Deck/DroidDeck/pull/151))
- The Proton selected in Components stays in sync with Steam's default compatibility tool, and runnable builds are marked **Compatible**. Windows titles with a Valve recommendation use that default unless you choose another compatible tool for the game. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197), [#224](https://github.com/Droid-Deck/DroidDeck/pull/224))
### Compatibility and performance
- Choose **esync**, **fsync**, experimental **ntsync**, or **wineserver** for Proton games. These sync options work around Android's missing kernel support, with esync as the default for fresh installs and existing choices preserved. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197), [#223](https://github.com/Droid-Deck/DroidDeck/pull/223))
- droiddeck-esync packs are matched to the exact Valve, GE-Proton or CachyOS Proton build and fetched automatically. droiddeck-fsync stands in when a pack is not available. (@maxjivi05, [#189](https://github.com/Droid-Deck/DroidDeck/pull/189), [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
- Linux-only x86 Steam titles can run through FEX using Steam's FEX and Steam Linux Runtime, with controller input and the device's graphics drivers. Missing dependencies are requested through Steam. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
- .NET and Godot games get automatic launch fixes under FEX, including the settings needed for Slay the Spire 2. Per-game overrides are kept. (@magarto, [#168](https://github.com/Droid-Deck/DroidDeck/pull/168))
### Display and effects
- Live image scaling with AMD FSR 1, Snapdragon GSR and GSR quality, NVIDIA NIS, linear and nearest filtering, plus sharpening. (@magarto, @The412Banner, [#128](https://github.com/Droid-Deck/DroidDeck/pull/128), [#198](https://github.com/Droid-Deck/DroidDeck/pull/198))
- An **Effects** page adds presets, FXAA, Toon, CRT, NTSC and Fake HDR. Advanced controls cover sharpening, debanding, brightness, contrast, gamma and saturation, with choices saved between sessions. (@The412Banner, @xXJSONDeruloXx, [#198](https://github.com/Droid-Deck/DroidDeck/pull/198), [#247](https://github.com/Droid-Deck/DroidDeck/pull/247))
- Anisotropic filtering and texture sharpness for DirectX 9–11 games, applied on the next game launch. (@The412Banner, [#198](https://github.com/Droid-Deck/DroidDeck/pull/198))
- One **Resolution** menu before starting a session, with preset dimensions, Custom and **Match screen**. Resolution, image scaling and fullscreen compatibility have separate controls. (@xXJSONDeruloXx, [#247](https://github.com/Droid-Deck/DroidDeck/pull/247))
### Controls and sessions
- Standard Android picture-in-picture, opened manually or automatically when leaving a ready session, with second-screen controls restored when you return. (@xXJSONDeruloXx, [#156](https://github.com/Droid-Deck/DroidDeck/pull/156), [#195](https://github.com/Droid-Deck/DroidDeck/pull/195))
- A quick toggle hides or restores on-screen controls. **Touch Off** ignores touches on the game picture while keeping Android controls usable. (@xXJSONDeruloXx, [#147](https://github.com/Droid-Deck/DroidDeck/pull/147), [#196](https://github.com/Droid-Deck/DroidDeck/pull/196))
- Optional game vibration and on-screen Steam, Quick Access and keyboard buttons. (@xXJSONDeruloXx, [#194](https://github.com/Droid-Deck/DroidDeck/pull/194))
- Steam session settings use tabs for Display, Controls, Games, Audio, Session and Steam. The session menu starts with Controller, gives second-screen modes their own page, and groups live display changes separately from settings for the next game launch. (@xXJSONDeruloXx, [#247](https://github.com/Droid-Deck/DroidDeck/pull/247))
- Compact Steam Home and Quick Access shortcuts beside Stop. Switching to Desktop stays in Steam's own menu. (@xXJSONDeruloXx, [#188](https://github.com/Droid-Deck/DroidDeck/pull/188), [#196](https://github.com/Droid-Deck/DroidDeck/pull/196))
- Back's double-press window is back to 500 ms, making Quick Access easier to open. (@xXJSONDeruloXx, [#193](https://github.com/Droid-Deck/DroidDeck/pull/193))
### Apps and launcher
- Android home-screen shortcuts for installed Steam games and added games. (@xXJSONDeruloXx, [#157](https://github.com/Droid-Deck/DroidDeck/pull/157))
- Export per-game `.droiddeck` launch files and Steam icons for Android frontends, with optional automatic folder sync as the library changes. (@xXJSONDeruloXx, [#179](https://github.com/Droid-Deck/DroidDeck/pull/179))
- Import x86 AppImages and run x86 Linux scripts and programs through FEX. Added apps have a **Use FEX** Auto/On/Off choice, and GitHub imports can use an x86_64 AppImage when no ARM64 build is available. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
- Vulkan Qt Quick AppImages such as OpenNOW use Wayland to avoid X11 startup failures. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
- Preview updates show recent published changes and how many builds behind you are when the installed Preview can be matched. (@xXJSONDeruloXx, [#159](https://github.com/Droid-Deck/DroidDeck/pull/159))
- A new empty-library intro and DroidDeck wordmark on the Steam page, plus an option to reduce app animations. (@xXJSONDeruloXx, [#182](https://github.com/Droid-Deck/DroidDeck/pull/182), [#194](https://github.com/Droid-Deck/DroidDeck/pull/194))
- The Linux file manager bookmarks the selected game library automatically. (@xXJSONDeruloXx, [#194](https://github.com/Droid-Deck/DroidDeck/pull/194))
### Languages and logs
- Simplified Chinese. (@Dantalian-dev, [#202](https://github.com/Droid-Deck/DroidDeck/pull/202))
- Japanese, Korean and Traditional Chinese for Taiwan, Hong Kong and Macau. (@starwishHi, [#216](https://github.com/Droid-Deck/DroidDeck/pull/216))
- Optional **Storage diagnostics** include allocation and storage timing in shared session logs to help investigate slow Steam installs. (@xXJSONDeruloXx, [#158](https://github.com/Droid-Deck/DroidDeck/pull/158))
- Session logs retain their final output and include more detail when an AppImage or FEX program fails to start or crashes. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
### Fixed
- Steam's Deck controller not being found on handhelds with older kernels. (@xXJSONDeruloXx, [#146](https://github.com/Droid-Deck/DroidDeck/pull/146))
- Physical and on-screen controllers doing nothing on ColorOS devices with linked app data directories. (@maxjivi05, [#242](https://github.com/Droid-Deck/DroidDeck/pull/242))
- Game vibration going to the phone instead of the active physical controller. The device vibrator remains the fallback for on-screen controls or controllers without motors. (@maxjivi05, [#242](https://github.com/Droid-Deck/DroidDeck/pull/242))
- A physical mouse stopping camera movement at the screen edge. Games that request a pointer lock now use Android's relative pointer capture, released while menus or PiP are open. (@maxjivi05, [#242](https://github.com/Droid-Deck/DroidDeck/pull/242))
- Steam remaining on a black screen after idle sleep or Android session resume. (@xXJSONDeruloXx, @jmarti326, [#155](https://github.com/Droid-Deck/DroidDeck/pull/155), [#172](https://github.com/Droid-Deck/DroidDeck/pull/172))
- Steam stuck at **Waiting for Steam** after an interrupted client update. After two starts without a frame, DroidDeck fetches the client again while preserving games, settings and user data; **Repair Steam client** also queues a manual repair. (@maxjivi05, [#225](https://github.com/Droid-Deck/DroidDeck/pull/225))
- Play racing runtime removal. Removal now shows progress and verifies completion, and first-run setup respects the selected Steam channel and saved UI scaling. (@xXJSONDeruloXx, [#185](https://github.com/Droid-Deck/DroidDeck/pull/185))
- GE-Proton and CachyOS Proton failing their protonfixes checks because Android blocks `/proc/sys/fs/file-max`. (@magarto, [#169](https://github.com/Droid-Deck/DroidDeck/pull/169))
- DirectAudio not being selected correctly, mismatched FEX helpers surviving component swaps, and FEX or its runtime not being executable from an SD-card Steam library. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
[Full changelog](https://github.com/Droid-Deck/DroidDeck/compare/0.3.0...0.3.1)
@@ -775,6 +775,10 @@ BUSCONF
echo "== steam: client branch on disk was '$(cat "$steam_beta" 2>/dev/null)', set to '$steam_channel' to match -clientbeta"
echo "$steam_channel" > "$steam_beta"
fi
if [ -z "${_bl_counted:-}" ] && [ -z "${BL_DESKTOP:-}" ]; then
echo start >> "$steam_root/package/droiddeck-unconfirmed"
_bl_counted=1
fi
step "starting the Steam client${BL_STEAM_UI:+ ($BL_STEAM_UI UI)}"
"$steam_root/steamrtarm64/steam" "${_bl_ui[@]}" "${_bl_cdp[@]}" -clientbeta "$steam_channel" \
-no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 \
@@ -12,9 +12,33 @@ manifest=steam_client_${steam_channel}_linuxarm64
steam_root=${XDG_DATA_HOME:-$HOME/.local/share}/Steam
stamp=$steam_root/package/droiddeck-installed
legacy_stamp=$steam_root/package/bannerlator-installed
unconfirmed=$steam_root/package/droiddeck-unconfirmed
repair_request=$HOME/.bl-steam-repair
if [ -f "$legacy_stamp" ] && [ ! -f "$stamp" ]; then
mv -f "$legacy_stamp" "$stamp" || true
fi
repair=""
if [ -e "$repair_request" ]; then
repair="requested"
elif [ -f "$unconfirmed" ] && ! grep -qx repaired "$unconfirmed" \
&& [ "$(grep -cx start "$unconfirmed")" -ge 2 ]; then
repair="the client was started $(grep -cx start "$unconfirmed") times without showing anything"
fi
if [ -n "$repair" ]; then
echo "== STEP $(date -u +%H:%M:%S) repairing the Steam client ($repair)"
rm -f "$stamp" "$legacy_stamp" "$steam_root/.crash" "$steam_root/steam.pid" \
"$steam_root"/package/steam_client_*.manifest "$steam_root"/package/steam_client_*.installed
for part in "$steam_root"/package/*.zip.*; do
[ -f "$part" ] || continue
sum=${part##*.}
sum=${sum%%_*}
case $sum in
*[!0-9a-f]*|"") continue ;;
esac
[ ${#sum} -eq 40 ] || continue
[ "$(sha1sum "$part" | cut -d' ' -f1)" = "$sum" ] || rm -f "$part"
done
fi
[ -x "$steam_root/steamrtarm64/steam" ] && [ -f "$stamp" ] && exit 0
work=$(mktemp -d)
@@ -68,3 +92,7 @@ ln -sfn "$steam_root/linuxarm64" "$HOME/.steam/sdkarm64"
ln -sfn "$steam_root/steamrtarm64" "$HOME/.steam/binarm64"
ln -sfn "$steam_root/linux32" "$HOME/.steam/sdk32"
echo "$version" > "$stamp"
if [ -n "$repair" ]; then
echo repaired > "$unconfirmed"
rm -f "$repair_request"
fi
+63
View File
@@ -38,6 +38,10 @@ cp "$TEST_CDN/${2##*/}" "$4"
sha = self.bin / 'sha256sum'
sha.write_text('#!/bin/bash\nexec shasum -a 256 "$@"\n')
sha.chmod(0o755)
if shutil.which('sha1sum') is None:
sha1 = self.bin / 'sha1sum'
sha1.write_text('#!/bin/bash\nexec shasum -a 1 "$@"\n')
sha1.chmod(0o755)
self.env = dict(os.environ, HOME=str(self.home), XDG_DATA_HOME=str(self.data),
PATH=str(self.bin) + os.pathsep + os.environ['PATH'],
TEST_CDN=str(self.cdn), TEST_REQUESTS=str(self.requests))
@@ -104,6 +108,65 @@ cp "$TEST_CDN/${2##*/}" "$4"
self.assertEqual(0, self.install('publicbeta').returncode)
self.assertEqual('publicbeta\n', (self.steam / 'package/beta').read_text())
def break_client(self, starts):
self.assert_installed('steamdeck_publicbeta')
self.requests.unlink()
package = self.steam / 'package'
(self.steam / 'steamrtarm64/steam').write_text('half updated')
(package / 'steam_client_steamdeck_publicbeta_linuxarm64.manifest').write_text('manifest')
(package / 'steam_client_steamdeck_publicbeta_linuxarm64.installed').write_text('installed')
good = b'complete package'
(package / ('bins_linuxarm64.zip.vz.' + hashlib.sha1(good).hexdigest() + '_16')).write_bytes(good)
(package / ('bins_misc_linuxarm64.zip.vz.' + hashlib.sha1(b'whole').hexdigest() + '_5')).write_bytes(b'wh')
(package / ('strings_all.zip.' + hashlib.sha1(b'strings').hexdigest())).write_bytes(b'strings')
(self.steam / '.crash').write_text('')
(package / 'droiddeck-unconfirmed').write_text('start\n' * starts)
return package
def test_two_starts_with_nothing_shown_fetch_the_client_again(self):
package = self.break_client(2)
self.assert_installed('steamdeck_publicbeta')
self.assertEqual('repaired\n', (package / 'droiddeck-unconfirmed').read_text())
self.assertFalse((self.steam / '.crash').exists())
self.assertEqual([], sorted(p.name for p in package.glob('steam_client_*')))
self.assertEqual(['bins_linuxarm64.zip.vz.', 'strings_all.zip.'],
sorted(p.name[:p.name.rindex('.') + 1] for p in package.glob('*.zip.*')))
def test_one_start_with_nothing_shown_is_left_alone(self):
package = self.break_client(1)
self.assertEqual(0, self.install().returncode)
self.assertFalse(self.requests.exists())
self.assertEqual('half updated', (self.steam / 'steamrtarm64/steam').read_text())
self.assertTrue((package / 'steam_client_steamdeck_publicbeta_linuxarm64.installed').exists())
def test_a_repair_is_not_repeated_until_the_client_has_been_seen(self):
package = self.break_client(2)
self.assertEqual(0, self.install().returncode)
self.requests.unlink()
with (package / 'droiddeck-unconfirmed').open('a') as marker:
marker.write('start\nstart\nstart\n')
self.assertEqual(0, self.install().returncode)
self.assertFalse(self.requests.exists())
def test_requested_repair_runs_once_and_is_retried_until_it_completes(self):
package = self.break_client(0)
(package / 'droiddeck-unconfirmed').unlink()
request = self.home / '.bl-steam-repair'
request.write_text('1\n')
archive = self.cdn / 'steamdeck_publicbeta.zip'
original = archive.read_bytes()
archive.write_bytes(b'corrupt download')
self.assertNotEqual(0, self.install().returncode)
self.assertTrue(request.exists())
self.assertFalse((package / 'droiddeck-installed').exists())
archive.write_bytes(original)
self.requests.unlink()
self.assert_installed('steamdeck_publicbeta')
self.assertFalse(request.exists())
self.requests.unlink()
self.assertEqual(0, self.install().returncode)
self.assertFalse(self.requests.exists())
def test_invalid_channel_fails_before_any_download(self):
result = self.install('../unknown')
self.assertNotEqual(0, result.returncode)