From dde1a55d00a6bb8964018efc1e0db42048740e05 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:49:51 -0400 Subject: [PATCH] feat(input): pass held Back through as Steam QAM --- .../com/droiddeck/launcher/SessionActivity.kt | 15 +++ .../droiddeck/launcher/input/BackQamInput.kt | 42 +++++++ .../droiddeck/launcher/input/PadBridge.java | 17 ++- .../launcher/ui/ModeSettingsDialog.kt | 4 +- .../droiddeck/launcher/ui/SessionOverlay.kt | 4 +- app/src/main/res/values/strings.xml | 1 + .../launcher/input/BackQamInputTest.kt | 90 ++++++++++++++ .../launcher/input/PadBridgeQamTest.kt | 115 ++++++++++++++++++ 8 files changed, 285 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/input/BackQamInput.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/input/BackQamInputTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/input/PadBridgeQamTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index b9dce0e41..7599185ff 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -257,6 +257,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private val cursorResync = Runnable { if (syncClientCursor() && clientHidesCursor) cursorVisible = false } private val uiHandler = Handler(Looper.getMainLooper()) private var pendingBackAction: Runnable? = null + private val backQamInput = com.droiddeck.launcher.input.BackQamInput { padBridge?.setBackQamPressed(it) } private var drawerDirectionKey = KeyEvent.KEYCODE_UNKNOWN private var drawerDirectionDownTime = 0L private var drawerDirectionDeviceId = -1 @@ -446,6 +447,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } override fun onInputDeviceRemoved(deviceId: Int) { Log.i(TAG, "input device $deviceId disconnected") + backQamInput.onDeviceRemoved(deviceId) joyConVr?.refresh() padBridge?.onDeviceRemoved(deviceId) if (::padMouse.isInitialized) padMouse.reset() @@ -773,6 +775,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } LaunchedEffect(drawerOpen, pcKeyboardOpen, achievementsOpen, gestureSettingsOpen, pipUi, SessionState.suspended) { if (drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || pipUi || SessionState.suspended) { + backQamInput.cancel() cancelTouchGestures() screenKeys?.stopEditing() } @@ -2106,6 +2109,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } private fun releasePads() { + backQamInput.cancel() padBridge?.releaseAll() joyConVr?.releaseButtons() } @@ -2184,6 +2188,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_MUTE -> return super.dispatchKeyEvent(event) } + val backToQam = !pipUi && !screenKeyTextFocus && !drawerOpen && !pcKeyboardOpen && !achievementsOpen && + SessionState.mode == SessionService.MODE_STEAM && SessionState.running && SessionState.firstFrameSeen && + !SessionState.suspended && padBridge?.canHoldQam() == true + if (backQamInput.onKeyEvent(event, backToQam)) { + pendingBackAction?.let(uiHandler::removeCallbacks) + pendingBackAction = null + return true + } if (pipUi) return true if (screenKeyTextFocus) return super.dispatchKeyEvent(event) val pad = com.droiddeck.launcher.input.JoyCon.normalize(event) @@ -3253,6 +3265,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onPause() { if (runtimeRemovalBlocked) { super.onPause(); return } + backQamInput.cancel() capturePausedFrame() sessionClipboard.stop() if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel() @@ -3636,6 +3649,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus) + if (!hasFocus) backQamInput.cancel() if (!hasFocus && ::padMouse.isInitialized) padMouse.reset() if (runtimeRemovalBlocked) return if (!hasFocus) { @@ -3683,6 +3697,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } override fun onDestroy() { + backQamInput.cancel() steamVrRecovery.close() uiHandler.removeCallbacks(steamTouchRetry) if (runtimeRemovalBlocked) { super.onDestroy(); return } diff --git a/app/src/main/java/com/droiddeck/launcher/input/BackQamInput.kt b/app/src/main/java/com/droiddeck/launcher/input/BackQamInput.kt new file mode 100644 index 000000000..4ba4e3391 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/BackQamInput.kt @@ -0,0 +1,42 @@ +package com.droiddeck.launcher.input + +import android.view.KeyEvent + +class BackQamInput(private val setPressed: (Boolean) -> Unit) { + private val held = mutableSetOf() + private val cancelled = mutableSetOf() + + fun onKeyEvent(event: KeyEvent, enabled: Boolean): Boolean { + if (!enabled) cancel() + if (event.keyCode != KeyEvent.KEYCODE_BACK) return false + val device = event.deviceId + return when (event.action) { + KeyEvent.ACTION_DOWN -> { + if (event.repeatCount > 0) return enabled || device in held || device in cancelled + cancelled.remove(device) + if (!enabled || event.isCanceled) return false + if (held.add(device) && held.size == 1) setPressed(true) + true + } + KeyEvent.ACTION_UP -> { + val released = held.remove(device) + val wasCancelled = cancelled.remove(device) + if (released && held.isEmpty()) setPressed(false) + released || wasCancelled || enabled + } + else -> device in held || device in cancelled + } + } + + fun cancel() { + if (held.isEmpty()) return + cancelled.addAll(held) + held.clear() + setPressed(false) + } + + fun onDeviceRemoved(device: Int) { + cancelled.remove(device) + if (held.remove(device) && held.isEmpty()) setPressed(false) + } +} 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 0811cab05..2ed8037af 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java +++ b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java @@ -61,6 +61,7 @@ public final class PadBridge { private final PadState routedTouch = new PadState(); private boolean systemGuidePressed; private boolean systemQamPressed; + private boolean backQamPressed; private boolean qamChordActive; private int chordButton = -1; private boolean chordButtonPressed; @@ -132,6 +133,7 @@ public final class PadBridge { routedTouch.clear(); systemGuidePressed = false; systemQamPressed = false; + backQamPressed = false; qamTapPressed = false; qamChordActive = false; chordButtonPressed = false; @@ -165,6 +167,7 @@ public final class PadBridge { } systemGuidePressed = false; systemQamPressed = false; + backQamPressed = false; qamChordActive = false; chordButtonPressed = false; qamTapPressed = false; @@ -459,6 +462,7 @@ public final class PadBridge { public synchronized void releaseAll() { routedTouch.clear(); + backQamPressed = false; releaseBackPaddles(); state.clear(); publish(); @@ -479,6 +483,17 @@ public final class PadBridge { return SessionState.getDeckPad() && FakeInputWriter.padHasQam(); } + public boolean canHoldQam() { + return builtinPad && nativeQam(); + } + + public synchronized void setBackQamPressed(boolean pressed) { + if (pressed && !canHoldQam()) return; + if (backQamPressed == pressed) return; + backQamPressed = pressed; + publish(); + } + public synchronized void triggerQam() { if (!builtinPad) return; if (!nativeQam()) { @@ -684,7 +699,7 @@ public final class PadBridge { return; } boolean guide = systemGuidePressed || qamChordActive; - boolean qam = nativeQam() && (systemQamPressed || qamTapPressed); + boolean qam = nativeQam() && (systemQamPressed || qamTapPressed || backQamPressed); if (!guide && !qam) { writer.writePad(state); return; 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 86c03309f..78fc3b00c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -306,7 +306,9 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: stringResource(R.string.unfolded_controls_with_controller_hint), s.unfoldedControlsWithController, onChange = a.onUnfoldedControlsWithController, ) - if (steam) ChoiceRow( + if (steam && s.steamController in listOf(SessionPrefs.CONTROLLER_DECK, SessionPrefs.CONTROLLER_STEAM)) { + Note(stringResource(R.string.back_native_qam_hint)) + } else if (steam) ChoiceRow( host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)), listOf( false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM), 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 b526bea28..bed8cccbd 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -604,7 +604,9 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont chipModifier = focus.track(page, "keyboard-button"), onChange = a.onKeyboardButton) } } - if (a.steam) ChoiceRow(host, "back-actions", stringResource(R.string.mode_back), null, + if (a.steam && a.padType in listOf(SessionPrefs.CONTROLLER_DECK, SessionPrefs.CONTROLLER_STEAM)) { + Note(stringResource(R.string.back_native_qam_hint)) + } else if (a.steam) ChoiceRow(host, "back-actions", stringResource(R.string.mode_back), null, listOf(false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM), true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU)), a.backActionsInverted, chipModifier = focus.track(page, "back-actions"), onPick = a.onBackActionsInverted) if (a.steam && a.unfoldedControls != null) ToggleRow(host, "unfolded-controls", stringResource(R.string.unfolded_controls), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6b598b8c2..6cf0674c2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -306,6 +306,7 @@ Xbox 360 controller Deck: Quick Access button and gyro. Steam Controller: the same, with grip sense on the back buttons. DualShock 4 and DualSense: gyro, and the trackpads as their touchpad. Xbox 360: open Quick Access with Guide+A. Back + Back acts as Quick Access. Hold it with another button for Steam shortcuts. Menu → QAM QAM → Menu Session diff --git a/app/src/test/java/com/droiddeck/launcher/input/BackQamInputTest.kt b/app/src/test/java/com/droiddeck/launcher/input/BackQamInputTest.kt new file mode 100644 index 000000000..37a7abda0 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/BackQamInputTest.kt @@ -0,0 +1,90 @@ +package com.droiddeck.launcher.input + +import android.view.KeyEvent +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], manifest = Config.NONE) +class BackQamInputTest { + private val changes = mutableListOf() + private val input = BackQamInput(changes::add) + + @Test fun aHoldAndItsRepeatsProduceOnePressAndOneRelease() { + assertTrue(send(KeyEvent.ACTION_DOWN)) + for (repeat in 1..10) assertTrue(send(KeyEvent.ACTION_DOWN, repeat = repeat)) + assertEquals(listOf(true), changes) + assertTrue(send(KeyEvent.ACTION_UP)) + assertEquals(listOf(true, false), changes) + } + + @Test fun otherButtonsAndSelectPassThroughWhileBackIsHeld() { + send(KeyEvent.ACTION_DOWN) + for (code in listOf(KeyEvent.KEYCODE_BUTTON_X, KeyEvent.KEYCODE_BUTTON_SELECT)) { + for (action in listOf(KeyEvent.ACTION_DOWN, KeyEvent.ACTION_UP)) { + assertFalse(input.onKeyEvent(KeyEvent(action, code), true)) + } + } + assertEquals(listOf(true), changes) + send(KeyEvent.ACTION_UP) + assertEquals(listOf(true, false), changes) + } + + @Test fun disabledRoutingPreservesBackNavigation() { + assertFalse(send(KeyEvent.ACTION_DOWN, enabled = false)) + assertFalse(send(KeyEvent.ACTION_UP, enabled = false)) + assertTrue(changes.isEmpty()) + } + + @Test fun losingFocusReleasesQamAndConsumesTheLateRelease() { + send(KeyEvent.ACTION_DOWN) + input.cancel() + input.cancel() + assertTrue(send(KeyEvent.ACTION_DOWN, repeat = 1, enabled = false)) + assertTrue(send(KeyEvent.ACTION_UP, enabled = false)) + assertEquals(listOf(true, false), changes) + assertFalse(send(KeyEvent.ACTION_UP, enabled = false)) + } + + @Test fun openingAnOverlayReleasesQamBeforeRoutingItsNextKey() { + send(KeyEvent.ACTION_DOWN) + assertFalse(input.onKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BUTTON_B), false)) + assertTrue(send(KeyEvent.ACTION_UP, enabled = false)) + assertEquals(listOf(true, false), changes) + } + + @Test fun repeatsAfterCancellationCannotReassertQam() { + send(KeyEvent.ACTION_DOWN) + input.cancel() + assertTrue(send(KeyEvent.ACTION_DOWN, repeat = 1)) + assertEquals(listOf(true, false), changes) + assertTrue(send(KeyEvent.ACTION_DOWN)) + assertTrue(send(KeyEvent.ACTION_UP)) + assertEquals(listOf(true, false, true, false), changes) + } + + @Test fun cancelledReleaseStillReleasesQam() { + send(KeyEvent.ACTION_DOWN) + assertTrue(input.onKeyEvent(KeyEvent(0, 100, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_BACK, + 0, 0, 4, 0, KeyEvent.FLAG_CANCELED), true)) + assertEquals(listOf(true, false), changes) + } + + @Test fun removingOneDeviceDoesNotReleaseAnotherDevicesHold() { + send(KeyEvent.ACTION_DOWN, device = 4) + send(KeyEvent.ACTION_DOWN, device = 5) + input.onDeviceRemoved(4) + assertTrue(changes.last()) + send(KeyEvent.ACTION_UP, device = 5) + assertFalse(changes.last()) + send(KeyEvent.ACTION_DOWN, device = 4) + input.onDeviceRemoved(4) + assertFalse(changes.last()) + } + + private fun send(action: Int, repeat: Int = 0, device: Int = 4, enabled: Boolean = true) = + input.onKeyEvent(KeyEvent(0, 100, action, KeyEvent.KEYCODE_BACK, repeat, 0, device, 0), enabled) +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/PadBridgeQamTest.kt b/app/src/test/java/com/droiddeck/launcher/input/PadBridgeQamTest.kt new file mode 100644 index 000000000..3491076a2 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/PadBridgeQamTest.kt @@ -0,0 +1,115 @@ +package com.droiddeck.launcher.input + +import android.os.Handler +import com.droiddeck.launcher.session.SessionState +import java.nio.file.Files +import java.time.Duration +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.util.ReflectionHelpers + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], manifest = Config.NONE, instrumentedPackages = ["com.droiddeck.launcher.input.FakeInputWriter"], + shadows = [JoyConPadTest.RecordingWriter::class]) +class PadBridgeQamTest { + private val directory = Files.createTempDirectory("qam-pad").toFile() + private lateinit var bridge: PadBridge + + @Before fun start() { + JoyConPadTest.RecordingWriter.pads.clear() + ReflectionHelpers.setStaticField(FakeInputWriter::class.java, "padType", FakeInputWriter.PAD_TYPE_DECK) + SessionState.deckPad = true + bridge = PadBridge(directory) + assertTrue(bridge.start()) + } + + @After fun stop() { + bridge.stop() + SessionState.deckPad = false + directory.deleteRecursively() + } + + @Test fun onlyNativeQamControllersAcceptAHeldBack() { + for (type in listOf(-1, FakeInputWriter.PAD_TYPE_DECK, FakeInputWriter.PAD_TYPE_STEAM, + FakeInputWriter.PAD_TYPE_XBOX, FakeInputWriter.PAD_TYPE_DS4, FakeInputWriter.PAD_TYPE_DUALSENSE)) { + ReflectionHelpers.setStaticField(FakeInputWriter::class.java, "padType", type) + val supported = type <= FakeInputWriter.PAD_TYPE_STEAM + assertEquals(supported, bridge.canHoldQam()) + bridge.setBackQamPressed(true) + bridge.applyExternal({}, false) + assertEquals(supported, pad().isDown(PadState.QAM)) + bridge.setBackQamPressed(false) + } + SessionState.deckPad = false + assertFalse(bridge.canHoldQam()) + } + + @Test fun qamAndOtherButtonsShareAReportUntilBackIsReleased() { + bridge.setBackQamPressed(true) + advanceTime() + bridge.applyExternal({ it.press(PadState.X, true); it.press(PadState.SELECT, true) }, true) + assertTrue(pad().isDown(PadState.QAM)) + assertTrue(pad().isDown(PadState.X)) + assertTrue(pad().isDown(PadState.SELECT)) + bridge.setBackQamPressed(false) + assertFalse(pad().isDown(PadState.QAM)) + assertTrue(pad().isDown(PadState.X)) + assertTrue(pad().isDown(PadState.SELECT)) + } + + @Test fun touchQamAndBackHaveIndependentReleases() { + bridge.setBackQamPressed(true) + bridge.setSystemButtons(false, true) + bridge.setBackQamPressed(false) + assertTrue(pad().isDown(PadState.QAM)) + bridge.setBackQamPressed(true) + bridge.setSystemButtons(false, false) + assertTrue(pad().isDown(PadState.QAM)) + bridge.setBackQamPressed(false) + assertFalse(pad().isDown(PadState.QAM)) + } + + @Test fun aTimedQamTapCannotReleaseAHeldBack() { + bridge.triggerQam() + bridge.setBackQamPressed(true) + advanceTime() + assertTrue(pad().isDown(PadState.QAM)) + bridge.setBackQamPressed(false) + assertFalse(pad().isDown(PadState.QAM)) + } + + @Test fun releasingAllOrStoppingClearsTheHold() { + bridge.setBackQamPressed(true) + bridge.releaseAll() + assertFalse(pad().isDown(PadState.QAM)) + bridge.setBackQamPressed(true) + bridge.stop() + bridge.start() + bridge.applyExternal({}, false) + assertFalse(pad().isDown(PadState.QAM)) + } + + @Test fun disablingTheBuiltinPadClearsAndRejectsTheHold() { + bridge.setBackQamPressed(true) + bridge.setBuiltinPad(false) + assertFalse(bridge.canHoldQam()) + assertFalse(pad().isDown(PadState.QAM)) + bridge.setBackQamPressed(true) + bridge.setBuiltinPad(true) + bridge.applyExternal({}, false) + assertFalse(pad().isDown(PadState.QAM)) + } + + private fun pad() = requireNotNull(JoyConPadTest.RecordingWriter.pads[0]) + + private fun advanceTime() { + val handler = ReflectionHelpers.getStaticField(PadBridge::class.java, "chordHandler") + shadowOf(handler.looper).idleFor(Duration.ofSeconds(2)) + } +}