diff --git a/app/build.gradle b/app/build.gradle index d6d2a4ab9..d92138f39 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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}" diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 0414ba2b0..0df7b586f 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -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(null) - private var resolutionCap by mutableStateOf(1080) - private var customResolution by mutableStateOf?>(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) diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 9d7fa640e..1d5ed29bf 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -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(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(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>(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 { - 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() } diff --git a/app/src/main/java/com/droiddeck/launcher/core/AppUiPrefs.kt b/app/src/main/java/com/droiddeck/launcher/core/AppUiPrefs.kt new file mode 100644 index 000000000..3021b7196 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/core/AppUiPrefs.kt @@ -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() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt index f95c53df8..48992e1eb 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt @@ -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") diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt index 88c2462ed..c0a4bcec3 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt @@ -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) } diff --git a/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt b/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt index d892c1241..2e66d4daa 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt @@ -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, diff --git a/app/src/main/java/com/droiddeck/launcher/input/FakeInputRingPaths.java b/app/src/main/java/com/droiddeck/launcher/input/FakeInputRingPaths.java new file mode 100644 index 000000000..05e5fc306 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/FakeInputRingPaths.java @@ -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(); + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java b/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java index 0e4ea2de3..8e617a8d6 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java +++ b/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java @@ -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) { diff --git a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java index a385650e6..08328b5d3 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java +++ b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java @@ -76,6 +76,17 @@ public final class PadBridge { private final java.util.Set 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 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(); diff --git a/app/src/main/java/com/droiddeck/launcher/session/BatteryComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/BatteryComponent.kt index 9b0834e17..fc6526f59 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/BatteryComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/BatteryComponent.kt @@ -14,7 +14,7 @@ import kotlin.math.abs * The client reads `/sys/class/power_supply/BAT/...` - 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", diff --git a/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt index fcf79bd6e..4522068d8 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt @@ -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 + } } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt new file mode 100644 index 000000000..f1a342e1e --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt @@ -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): Pair = + (maxOf(panel.first, panel.second) and 1.inv()) to (minOf(panel.first, panel.second) and 1.inv()) + + fun resolveChoice(panel: Pair, choice: String): Pair = + 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): List = + listOf(DEFAULT_RESOLUTION, "1600x900", "1920x1080") + .filter { resolveChoice(panel, it) != screenSize(panel) } + MATCH_SCREEN + + fun panelSize(context: Context): Pair { + 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, cap: Int, shape: String, custom: Pair? = null): Pair { + 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): Boolean = + (size.second * 16 + 4) / 9 / 2 * 2 > size.first +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index cda903be3..220544604 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -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): 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? = + private fun customResolution(context: Context, mode: String): Pair? = parseResolution(prefs(context).getString("customRes.$mode", null)) - fun setCustomResolution(context: Context, mode: String, size: Pair?) { - 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? { 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) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamRepair.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamRepair.kt new file mode 100644 index 000000000..38fc9c235 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/SteamRepair.kt @@ -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() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/AppScale.kt b/app/src/main/java/com/droiddeck/launcher/ui/AppScale.kt new file mode 100644 index 000000000..806ef22cd --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/AppScale.kt @@ -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() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt index 33226c3b3..3074257aa 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt @@ -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( diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index d8f8b2275..49f72e124 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -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 = {}, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index c404dab6f..e6b1777f2 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -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" } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt b/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt index c11a3cf0a..8dbaff287 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt @@ -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 diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index 714b6590e..e7ff70f31 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -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? = null, - val shapeMode: String, + val resolution: String, + val panelSize: Pair = 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>) class ModeSettingsActions( - val onResolution: (Int) -> Unit, - /** Null clears it. */ - val onCustomResolution: (Pair?) -> 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?, onSave: (Pair) -> Unit, onDismiss: () -> Unit) { +internal fun CustomResolutionDialog(initial: Pair?, onSave: (Pair) -> 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") diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ResolutionRow.kt b/app/src/main/java/com/droiddeck/launcher/ui/ResolutionRow.kt new file mode 100644 index 000000000..429ba1bb9 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/ResolutionRow.kt @@ -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, 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 }) +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt new file mode 100644 index 000000000..0955651cc --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt @@ -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.entries.filter { hasSecondScreen || it != SessionDrawerPage.SECOND_SCREEN } + +internal fun List.step(current: SessionDrawerPage, direction: Int): SessionDrawerPage { + val index = indexOf(current).coerceAtLeast(0) + return this[Math.floorMod(index + direction, size)] +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt index 5a3bb4e0d..d3d1df388 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt @@ -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) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index 5d0f851d2..cf05c2a5f 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -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() - private val last = arrayOfNulls(DRAWER_PAGES) + private val last = mutableMapOf() var focused by mutableStateOf(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(null) } var confirmStop by remember { mutableStateOf(false) } var sheetCoords by remember { mutableStateOf(null) } var stopCoords by remember { mutableStateOf(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, 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(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 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 diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt index dc8a345f1..38c510ae6 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt @@ -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 diff --git a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt index 331ad9507..45f9e4e35 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt @@ -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) } } diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java index 73e3c9a9e..84330c98b 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java +++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java @@ -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); + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 11c794fc0..b75e9c932 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -291,6 +291,10 @@ Public beta Steam Deck public beta Deck mode always uses Steam Deck public beta. + Repair Steam client + 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. + The Steam client will be downloaded again the next time Steam starts. + Repair Added games no game folders with a .exe found Games folder @@ -470,6 +474,7 @@ Effects Controls Components + Controller Settings %1$s page Steam @@ -485,8 +490,11 @@ Scaling sharpness Screen effects Live. Costs a little GPU time while any effect is on. - Look + Presets Custom + Advanced + Expanded + Collapsed Sharpening (CAS) Sharpening level Fake HDR @@ -510,6 +518,7 @@ PC keyboard Android Second screen + Mode Shows Android apps Launch an app @@ -586,6 +595,9 @@ Offline mode Sign in to Steam first Launcher + App scale + DroidDeck text and controls + %1$d%% (Default) Theme Launcher appearance Use as Home screen @@ -1011,4 +1023,25 @@ Choose a folder for frontend game files Game files saved to %1$s Frontend files + Session display + Resolution + 16:9 game fit + How 16:9 games fit this display. Next session. + Choose Match screen or a custom size narrower than 16:9. + Keep proportions + Stretch to display + Image scaling + Scaling filter + Shared by Steam and Desktop. + Applies live. Shared by Steam and Desktop. + FSR, GSR and NIS apply when enlarging the session image. Sharpen only also works without enlargement. + Filter sharpness + Frame rate + Game window compatibility + Force game windows fullscreen + Keeps games fullscreen after focus changes. Turn off if a game draws small. Applies live during a session. + Keeps games fullscreen after focus changes. Applies live. + %1$s Sets scaling to %2$s. + Match screen (%1$d×%2$d) + Steam diff --git a/app/src/test/java/com/droiddeck/launcher/input/FakeInputRingPathTest.kt b/app/src/test/java/com/droiddeck/launcher/input/FakeInputRingPathTest.kt new file mode 100644 index 000000000..d1d2b8d49 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/FakeInputRingPathTest.kt @@ -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")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/BatteryComponentTest.kt b/app/src/test/java/com/droiddeck/launcher/session/BatteryComponentTest.kt new file mode 100644 index 000000000..fd820e22b --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/BatteryComponentTest.kt @@ -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()) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/RumbleComponentTest.kt b/app/src/test/java/com/droiddeck/launcher/session/RumbleComponentTest.kt index f61e86058..293a48d58 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/RumbleComponentTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/RumbleComponentTest.kt @@ -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>() + 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 diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt new file mode 100644 index 000000000..b432110b6 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionScalingPrefsTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionScalingPrefsTest.kt new file mode 100644 index 000000000..aba88831a --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionScalingPrefsTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/AppScaleTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/AppScaleTest.kt new file mode 100644 index 000000000..56a6db50b --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/AppScaleTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/SessionDrawerPageTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/SessionDrawerPageTest.kt new file mode 100644 index 000000000..6c844ead1 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/SessionDrawerPageTest.kt @@ -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)) + } + } +} diff --git a/docs/development/evidence/thor-sd-storage-2026-10-04.txt b/docs/development/evidence/thor-sd-storage-2026-10-04.txt new file mode 100644 index 000000000..17961978f --- /dev/null +++ b/docs/development/evidence/thor-sd-storage-2026-10-04.txt @@ -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 diff --git a/docs/development/proot-performance.md b/docs/development/proot-performance.md index e9f18847b..3679f43ea 100644 --- a/docs/development/proot-performance.md +++ b/docs/development/proot-performance.md @@ -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 diff --git a/docs/development/thor-sd-storage-investigation.md b/docs/development/thor-sd-storage-investigation.md new file mode 100644 index 000000000..b2cfbce55 --- /dev/null +++ b/docs/development/thor-sd-storage-investigation.md @@ -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. diff --git a/docs/releases/0.3.1.md b/docs/releases/0.3.1.md new file mode 100644 index 000000000..17e22a47c --- /dev/null +++ b/docs/releases/0.3.1.md @@ -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) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 838a58136..91465d661 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -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 \ diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install index a5722f7d0..d2f59ed12 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install @@ -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 diff --git a/tools/tests/test_steam_install.py b/tools/tests/test_steam_install.py index 0d2052965..e62bba375 100644 --- a/tools/tests/test_steam_install.py +++ b/tools/tests/test_steam_install.py @@ -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)