feat(input): pass held Back through as Steam QAM

This commit is contained in:
Kurt Himebauch committed 2026-10-26 12:49:51 -04:00
1 parent 53d643dd00
commit dde1a55d00
8 files changed
+285 -3

No files matched your search

@@ -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 }
@@ -0,0 +1,42 @@
package com.droiddeck.launcher.input
import android.view.KeyEvent
class BackQamInput(private val setPressed: (Boolean) -> Unit) {
private val held = mutableSetOf<Int>()
private val cancelled = mutableSetOf<Int>()
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)
}
}
@@ -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;
@@ -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),
@@ -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),
+1
View File
@@ -306,6 +306,7 @@
<string name="mode_controller_x360">Xbox 360 controller</string>
<string name="mode_controller_note">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.</string>
<string name="mode_back">Back</string>
<string name="back_native_qam_hint">Back acts as Quick Access. Hold it with another button for Steam shortcuts.</string>
<string name="back_menu_then_qam">Menu → QAM</string>
<string name="back_qam_then_menu">QAM → Menu</string>
<string name="mode_session">Session</string>
@@ -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<Boolean>()
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)
}
@@ -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<Handler>(PadBridge::class.java, "chordHandler")
shadowOf(handler.looper).idleFor(Duration.ofSeconds(2))
}
}