From 472bb28c8841d6cec4ddf06407530e6034ec00ee Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Mon, 5 Oct 2026 07:33:33 -0400 Subject: [PATCH 1/6] Fix controller rings on linked data dirs, route rumble to the controller, capture the mouse for pointer locks (#242) - #166: export the fake-input ring paths with the spelling the session tree is bound under (Context.getFilesDir(), /data/user/0/...) instead of the canonical path. Where /data/user/0 resolves to /data/data (ColorOS 16) the guest has nothing bound at the canonical prefix, so libfakeinput could not open the rings and every pad, physical and on-screen, was dead. The ring path logic moves to FakeInputRingPaths so it can be unit-tested without the native library. - #219: play a game's rumble on the physical controller that last drove the pad, when Android exposes its motors (strong and weak separately on Android 12+), and fall back to the device's vibrator for the on-screen pad or a controller without rumble. PadBridge records the active controller; the on-screen pad taking over moves rumble back to the phone. - #153: when a program takes a pointer lock (a mouse-look game through gamescope), capture a physical mouse and send its raw deltas as relative motion, so the camera no longer stops where Android's pointer meets the screen edge. Capture is released for the drawer, the PC keyboard, picture in picture, focus loss and pause, and when the lock ends. --- .../com/droiddeck/launcher/SessionActivity.kt | 89 +++++++++++- .../launcher/input/FakeInputRingPaths.java | 39 ++++++ .../launcher/input/FakeInputWriter.java | 23 +-- .../droiddeck/launcher/input/PadBridge.java | 14 ++ .../launcher/session/RumbleComponent.kt | 132 ++++++++++++++---- .../launcher/wayland/WaylandCompositor.java | 12 ++ .../launcher/input/FakeInputRingPathTest.kt | 53 +++++++ .../launcher/session/RumbleComponentTest.kt | 71 +++++++++- 8 files changed, 383 insertions(+), 50 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/input/FakeInputRingPaths.java create mode 100644 app/src/test/java/com/droiddeck/launcher/input/FakeInputRingPathTest.kt 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/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/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/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/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/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 From 31ea194f3db0ff8d6b86eef0614442418ccf309a Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:46:36 -0400 Subject: [PATCH 2/6] Prepare 0.3.1 release (#263) * docs: draft 0.3.1 release notes * chore: bump version to 0.3.1 (11) --- app/build.gradle | 4 +-- docs/releases/0.3.1.md | 62 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 docs/releases/0.3.1.md 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/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) From c91daa9147ec685cf2f5d0954e7b99dfe9387cef Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:59:35 -0400 Subject: [PATCH 3/6] Keep the game undimmed while the session menu is open (#267) Remove background dimming from the session menu --- app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) { From b896e7a3b9081a92b43c8b9f4717fb1ac2056966 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:34:31 -0400 Subject: [PATCH 4/6] Compact Steam, Desktop, and Setup settings headers (#268) --- .../droiddeck/launcher/ui/FrontEndGames.kt | 2 +- .../droiddeck/launcher/ui/FrontEndSetup.kt | 5 +- .../launcher/ui/ModeSettingsDialog.kt | 46 ++++++++++--------- .../droiddeck/launcher/ui/SettingsWidgets.kt | 19 ++++++-- 4 files changed, 41 insertions(+), 31 deletions(-) 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/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index c404dab6f..d5d408414 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 -> { 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/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 From 06e06b9673be9b875c4537bbc79c576d397a66d7 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:07:40 -0400 Subject: [PATCH 5/6] Fix MangoHud double-counting battery power and remaining life (#270) --- .../launcher/session/BatteryComponent.kt | 8 ++- .../launcher/session/BatteryComponentTest.kt | 71 +++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/session/BatteryComponentTest.kt 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/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()) + } +} From b89d87cbde09f0d80c2ff34df684d5f461110c9c Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:25:51 -0400 Subject: [PATCH 6/6] Add app UI scale presets to Setup (#272) --- .../com/droiddeck/launcher/MainActivity.kt | 9 +- .../com/droiddeck/launcher/core/AppUiPrefs.kt | 19 ++++ .../launcher/files/FileManagerActivity.kt | 2 +- .../launcher/files/FilePickerActivity.kt | 2 +- .../com/droiddeck/launcher/ui/AppScale.kt | 23 +++++ .../droiddeck/launcher/ui/FrontEndScreen.kt | 2 + .../droiddeck/launcher/ui/FrontEndSetup.kt | 9 ++ .../java/com/droiddeck/launcher/ui/Theme.kt | 16 +++- app/src/main/res/values/strings.xml | 3 + .../com/droiddeck/launcher/ui/AppScaleTest.kt | 90 +++++++++++++++++++ 10 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/core/AppUiPrefs.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/AppScale.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/AppScaleTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 41e8241ba..0df7b586f 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -311,6 +311,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) @@ -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 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/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/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 d5d408414..e6b1777f2 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -311,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/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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 19f259ef2..b75e9c932 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/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) + } +}