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 01e10a3de..313a6aeea 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -319,6 +319,7 @@ class MainActivity : ComponentActivity() { private var steamChannel by mutableStateOf("steamdeck_publicbeta") private var runSteamAtStartup by mutableStateOf(false) private var theme by mutableStateOf("graphite") + 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) @@ -492,6 +493,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) @@ -501,7 +503,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 { @@ -528,6 +530,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, @@ -668,6 +671,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 diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 8ceab7bb7..1d5ed29bf 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -161,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) @@ -270,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() } @@ -324,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) @@ -387,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. @@ -417,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( @@ -1327,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)) { @@ -1677,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) { @@ -1848,6 +1930,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { controllerSettings = ControllerPrefs.read(this) updateOnScreenControls() resumed = true + updatePointerCapture() if (!pipUi) sessionClipboard.start() updatePadMotion() readPrefs() @@ -1867,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() @@ -1887,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. */ @@ -1937,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/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/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 ba3003a48..b426bcc4c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt @@ -303,7 +303,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 10f33cd43..a573e4946 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, @@ -171,6 +172,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/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index 8b14ec19f..e7ff70f31 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -155,7 +155,7 @@ class ModeSettingsActions( val onDismiss: () -> Unit, ) -private enum class SteamSettingsTab(val label: Int) { +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), @@ -171,34 +171,36 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { var confirmDeckyRemoval by remember { mutableStateOf(false) } var explainWifiDiscovery by remember { mutableStateOf(false) } var tabIndex by androidx.compose.runtime.saveable.rememberSaveable(s.mode) { mutableStateOf(0) } - val tabs = SteamSettingsTab.entries + 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 { tabs.map { androidx.compose.ui.focus.FocusRequester() } } + val tabFocus = remember(s.mode) { tabs.map { androidx.compose.ui.focus.FocusRequester() } } val firstChip = remember { androidx.compose.ui.focus.FocusRequester() } fun selectTab(index: Int) { host.open = null; tabIndex = index } androidx.compose.runtime.LaunchedEffect(steam, tabIndex) { androidx.compose.runtime.withFrameNanos { } runCatching { - if (!steam || tab == SteamSettingsTab.DISPLAY) firstChip.requestFocus() + 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 = scrolls[tabIndex], - modifier = if (steam) Modifier.bumpers( + modifier = Modifier.bumpers( onPrevious = { selectTab((tabIndex + tabs.size - 1) % tabs.size) }, onNext = { selectTab((tabIndex + 1) % tabs.size) }, - ) else Modifier, - header = if (steam) ({ + ), + header = { TabStrip(tabs.map { stringResource(it.label) }, tabIndex, ::selectTab, - modifier = Modifier.fillMaxWidth().padding(top = 12.dp), focusRequesters = tabFocus) - }) else null, + modifier = Modifier.fillMaxWidth(), focusRequesters = tabFocus) + }, ) { - if (!steam || tab == SteamSettingsTab.DISPLAY) { + 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, @@ -241,7 +243,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { } } } - if (!steam || tab == SteamSettingsTab.CONTROLS) { + 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, @@ -277,7 +279,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { ) } } - if (!steam || tab == SteamSettingsTab.SESSION) { + if (tab == ModeSettingsTab.SESSION) { SettingsGroup(stringResource(R.string.mode_session)) { ChoiceRow( host, "suspend", stringResource(R.string.mode_suspend), @@ -304,7 +306,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { ) } } - if (steam && tab == SteamSettingsTab.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 { @@ -363,7 +365,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { ) } } - if (steam && tab == SteamSettingsTab.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), @@ -389,7 +391,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { SecondaryButton(stringResource(R.string.mode_steam_repair_button), enabled = !s.steamRepairQueued, onClick = a.onSteamRepair) } } - if (steam && tab == SteamSettingsTab.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) @@ -408,11 +410,11 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { } } } - if ((!steam || tab == SteamSettingsTab.GAMES) && s.forceFullscreen != null) SettingsGroup(stringResource(R.string.display_window_compatibility)) { + 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 == SteamSettingsTab.GAMES && s.addedGamesDirs != null) SettingsGroup(stringResource(R.string.mode_added_games)) { + 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( @@ -437,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 && tab == SteamSettingsTab.GAMES && 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), @@ -460,7 +462,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { GameEnvironmentRow() } - if (steam && tab == SteamSettingsTab.AUDIO && 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), @@ -469,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 && tab == SteamSettingsTab.GAMES && 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))) @@ -505,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, 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 9280845b5..cf05c2a5f 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -308,7 +308,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont } } 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) { 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 64f56873e..38c510ae6 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt @@ -598,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, @@ -608,7 +608,7 @@ fun SettingsPage( scroll: androidx.compose.foundation.ScrollState? = null, scrollContent: Boolean = true, compactLayout: Boolean = false, - /** Controls that remain below the title while the settings scroll, such as a tab strip. */ + /** Fixed controls below the title, or beside Back when the title is omitted. */ header: (@Composable () -> Unit)? = null, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit, @@ -624,7 +624,18 @@ fun SettingsPage( ), 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( @@ -683,7 +694,7 @@ fun SettingsPage( } } } - if (header != null) header() + 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 e2a2d5801..b05eff898 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -595,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 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/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/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)