fix(input): handle immediate Back chords and tap timing (#1628)

* feat(input): support instant Back chords and tap timing

* fix(input): send QAM before shortcut inputs

* fix(input): stop delayed stick input on Back release
This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-26 15:04:55 -04:00
1 parent c8f650e167
commit 13eb291ba8
10 files changed
+425 -10

No files matched your search

@@ -260,6 +260,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private val backQamInput = com.droiddeck.launcher.input.BackQamInput(
setPressed = { padBridge?.setBackQamPressed(it) },
onTap = { performBackAction(double = it) },
doubleTapMs = { backDoubleTapMs.toLong() },
onCancel = { padBridge?.cancelBackQam() },
)
private var drawerDirectionKey = KeyEvent.KEYCODE_UNKNOWN
private var drawerDirectionDownTime = 0L
@@ -322,6 +324,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var drawerPage by mutableStateOf(SessionDrawerPage.CONTROLLER)
private var drawerControllerActive by mutableStateOf(false)
private var backActionsInverted by mutableStateOf(false)
private var backDoubleTapMs by mutableStateOf(500)
private var lowLatency by mutableStateOf(true)
private var padType by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
private var pcKeyboardOpen by mutableStateOf(false)
@@ -529,6 +532,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
bridge.setSelectSteam(ControllerPrefs.read(this).selectSteam)
bridge.setBuiltinPad(SessionState.mode != SessionService.MODE_STEAM || SessionPrefs.builtinPad(this))
padBridge = bridge
bridge.setOnChordInput(backQamInput::onChordInput)
SessionState.padBridge = bridge
if (Build.VERSION.SDK_INT >= 31) {
val vr = com.droiddeck.launcher.input.JoyConVr(this, File(filesDir, ".wayland-rt/${com.droiddeck.launcher.input.JoyConVr.FILE}")) { taken, released ->
@@ -1019,6 +1023,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
padType = id
},
backActionsInverted = backActionsInverted,
backDoubleTapMs = backDoubleTapMs,
onBackDoubleTapMs = { ms ->
SessionPrefs.setBackDoubleTapMs(this@SessionActivity, ms)
backDoubleTapMs = ms
},
onBackActionsInverted = { inverted ->
SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted)
backActionsInverted = inverted
@@ -1297,7 +1306,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
performBackAction(double = false)
}
pendingBackAction = pending
uiHandler.postDelayed(pending, 500L)
uiHandler.postDelayed(pending, backDoubleTapMs.toLong())
}
private fun performBackAction(double: Boolean) {
@@ -1631,6 +1640,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
oscMode = SessionPrefs.oscMode(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
backDoubleTapMs = SessionPrefs.backDoubleTapMs(this)
lowLatency = SessionPrefs.lowLatency(this)
padType = SessionPrefs.steamController(this)
}
@@ -4,7 +4,8 @@ import android.view.KeyEvent
import android.os.Handler
import android.os.Looper
class BackQamInput(private val setPressed: (Boolean) -> Unit, private val onTap: (Boolean) -> Unit) {
class BackQamInput(private val setPressed: (Boolean) -> Unit, private val onTap: (Boolean) -> Unit,
private val doubleTapMs: () -> Long = { 500 }, private val onCancel: () -> Unit = {}) {
private class Press(val hold: Runnable, var held: Boolean = false, var used: Boolean = false)
private val handler = Handler(Looper.getMainLooper())
private val presses = mutableMapOf<Int, Press>()
@@ -17,6 +18,18 @@ class BackQamInput(private val setPressed: (Boolean) -> Unit, private val onTap:
tapPending = false
}
fun onChordInput() {
if (presses.isEmpty()) return
val alreadyHeld = presses.values.any { it.held }
presses.values.forEach {
handler.removeCallbacks(it.hold)
it.held = true
it.used = true
}
cancelTap()
if (!alreadyHeld) setPressed(true)
}
fun onKeyEvent(event: KeyEvent, enabled: Boolean): Boolean {
if (!enabled) cancel()
if (event.keyCode != KeyEvent.KEYCODE_BACK) return false
@@ -48,7 +61,7 @@ class BackQamInput(private val setPressed: (Boolean) -> Unit, private val onTap:
if (press.held && presses.values.none { it.held }) setPressed(false)
if (!press.used && !event.isCanceled) {
if (tapPending) { cancelTap(); onTap(true) }
else { tapPending = true; handler.postDelayed(singleTap, 500) }
else { tapPending = true; handler.postDelayed(singleTap, doubleTapMs()) }
} else if (event.isCanceled) cancelTap()
}
press != null || wasCancelled || enabled
@@ -63,6 +76,7 @@ class BackQamInput(private val setPressed: (Boolean) -> Unit, private val onTap:
presses.values.forEach { handler.removeCallbacks(it.hold) }
cancelled.addAll(presses.keys)
presses.clear()
onCancel()
if (wasHeld) setPressed(false)
}
@@ -71,6 +85,9 @@ class BackQamInput(private val setPressed: (Boolean) -> Unit, private val onTap:
val press = presses.remove(device) ?: return
handler.removeCallbacks(press.hold)
cancelTap()
if (press.held && presses.values.none { it.held }) setPressed(false)
if (press.held && presses.values.none { it.held }) {
onCancel()
setPressed(false)
}
}
}
@@ -62,12 +62,18 @@ public final class PadBridge {
private boolean systemGuidePressed;
private boolean systemQamPressed;
private boolean backQamPressed;
private final PadState lastBackQamReport = new PadState();
private final PadState lastWrittenReport = new PadState();
private boolean backQamAxesBlocked;
private final java.util.Set<Runnable> backQamWrites = new java.util.HashSet<>();
private long backQamDelayMs, backQamWriteAt;
private boolean qamChordActive;
private int chordButton = -1;
private boolean chordButtonPressed;
private boolean qamTapPressed;
private int qamChordGeneration;
private volatile Runnable onPlayerInput;
private Runnable onChordInput;
private final java.util.concurrent.atomic.AtomicBoolean playerInputPosted = new java.util.concurrent.atomic.AtomicBoolean();
private static final long STATS_WINDOW_MS = 10_000;
@@ -130,6 +136,7 @@ public final class PadBridge {
}
private void resetBuiltin() {
clearBackQamWrites();
routedTouch.clear();
systemGuidePressed = false;
systemQamPressed = false;
@@ -152,6 +159,7 @@ public final class PadBridge {
}
public synchronized void stop() {
clearBackQamWrites();
releaseBackPaddles();
activeControllerId = NO_CONTROLLER;
for (int slot = 0; slot < SLOTS; slot++) {
@@ -276,6 +284,14 @@ public final class PadBridge {
onPlayerInput = listener;
}
public synchronized void setOnChordInput(Runnable listener) {
onChordInput = listener;
}
private void noteChordInput(int slot) {
if (slot == 0 && onChordInput != null) onChordInput.run();
}
private void notePlayerInput() {
Runnable listener = onPlayerInput;
if (listener == null || !playerInputPosted.compareAndSet(false, true)) return;
@@ -326,6 +342,7 @@ public final class PadBridge {
default: return false;
}
noteDevice(event.getDevice(), slot);
if (pressed && event.getRepeatCount() == 0 && !event.isCanceled()) noteChordInput(slot);
if (pressed) notePlayerInput();
statButtons++;
scheduleStats();
@@ -355,6 +372,7 @@ public final class PadBridge {
pad.rightX = x;
pad.rightY = y;
}
if (x != 0f || y != 0f) noteChordInput(slot);
if (Math.max(Math.abs(x), Math.abs(y)) > 0.5f) notePlayerInput();
publish(slot);
return true;
@@ -385,6 +403,8 @@ public final class PadBridge {
pad.up = hatY < -0.5f;
pad.down = hatY > 0.5f;
}
if (pad.leftX != 0f || pad.leftY != 0f || pad.rightX != 0f || pad.rightY != 0f
|| lt > 0f || rt > 0f || pad.up || pad.right || pad.down || pad.left) noteChordInput(slot);
if (Math.max(Math.max(Math.abs(pad.leftX), Math.abs(pad.leftY)), Math.max(Math.abs(pad.rightX), Math.abs(pad.rightY))) > 0.5f
|| lt > 0.5f || rt > 0.5f || pad.up || pad.right || pad.down || pad.left) {
notePlayerInput();
@@ -461,6 +481,7 @@ public final class PadBridge {
}
public synchronized void releaseAll() {
clearBackQamWrites();
routedTouch.clear();
backQamPressed = false;
releaseBackPaddles();
@@ -491,9 +512,108 @@ public final class PadBridge {
if (pressed && !canHoldQam()) return;
if (backQamPressed == pressed) return;
backQamPressed = pressed;
if (pressed && (open || start())) {
backQamAxesBlocked = false;
long now = android.os.SystemClock.uptimeMillis();
long modifierAt = Math.max(now, backQamWriteAt);
backQamDelayMs = modifierAt - now + QAM_GUIDE_LEAD_MIN_MS;
PadState modifier = new PadState();
modifier.copyFrom(lastBackQamReport);
modifier.press(PadState.QAM, true);
writeBackQamReport(modifier, modifierAt);
}
if (!pressed) {
backQamAxesBlocked = hasAxes(state);
if (!backQamWrites.isEmpty()) {
PadState neutral = new PadState();
neutral.copyFrom(lastWrittenReport);
clearAxes(neutral);
writer.writePad(neutral);
lastWrittenReport.copyFrom(neutral);
}
}
publish();
}
public synchronized void cancelBackQam() {
if (!backQamPressed && backQamWrites.isEmpty()) return;
clearBackQamWrites();
backQamPressed = false;
state.clear();
publish();
}
private void clearBackQamWrites() {
for (Runnable pending : backQamWrites) mainHandler.removeCallbacks(pending);
backQamWrites.clear();
backQamDelayMs = backQamWriteAt = 0;
lastBackQamReport.clear();
lastWrittenReport.clear();
backQamAxesBlocked = false;
}
private void writeBackQamReport(PadState report, long at) {
lastBackQamReport.copyFrom(report);
if (backQamWrites.isEmpty() && at <= android.os.SystemClock.uptimeMillis()) {
writeReport(report, false);
return;
}
PadState queued = new PadState();
queued.copyFrom(report);
Runnable pending = new Runnable() {
@Override public void run() {
synchronized (PadBridge.this) {
if (!backQamWrites.remove(this)) return;
writeReport(queued, true);
if (backQamWrites.isEmpty() && !hasButtons(lastBackQamReport)) backQamDelayMs = 0;
}
}
};
backQamWrites.add(pending);
backQamWriteAt = at;
mainHandler.postAtTime(pending, at);
}
private void writePad(PadState report) {
if (backQamAxesBlocked && !hasAxes(state)) backQamAxesBlocked = false;
long at = android.os.SystemClock.uptimeMillis();
// Steam must see the modifier before a partner; preserve quick press/release pairs in order.
if (backQamPressed || !backQamWrites.isEmpty()) at += backQamDelayMs;
writeBackQamReport(report, at);
}
private static boolean hasAxes(PadState pad) {
return pad.leftX != 0f || pad.leftY != 0f || pad.rightX != 0f || pad.rightY != 0f
|| pad.leftTrigger != 0f || pad.rightTrigger != 0f;
}
private static void clearAxes(PadState pad) {
pad.leftX = pad.leftY = pad.rightX = pad.rightY = pad.leftTrigger = pad.rightTrigger = 0f;
}
private static boolean hasButtons(PadState pad) {
for (int i = 0; i < 10; i++) if (pad.isDown(i)) return true;
return pad.isDown(PadState.GUIDE) || pad.up || pad.right || pad.down || pad.left;
}
private void writeReport(PadState report, boolean queued) {
if (!queued && !backQamAxesBlocked) {
lastWrittenReport.copyFrom(report);
writer.writePad(report);
return;
}
PadState live = new PadState();
live.copyFrom(report);
if (queued) {
live.leftX = state.leftX; live.leftY = state.leftY;
live.rightX = state.rightX; live.rightY = state.rightY;
live.leftTrigger = state.leftTrigger; live.rightTrigger = state.rightTrigger;
}
if ((queued && (!backQamPressed || !report.isDown(PadState.QAM))) || backQamAxesBlocked) clearAxes(live);
lastWrittenReport.copyFrom(live);
writer.writePad(live);
}
public synchronized void triggerQam() {
if (!builtinPad) return;
if (!nativeQam()) {
@@ -701,14 +821,14 @@ public final class PadBridge {
boolean guide = systemGuidePressed || qamChordActive;
boolean qam = nativeQam() && (systemQamPressed || qamTapPressed || backQamPressed);
if (!guide && !qam) {
writer.writePad(state);
writePad(state);
return;
}
effectiveState.copyFrom(state);
if (guide) effectiveState.press(PadState.GUIDE, true);
if (qam) effectiveState.press(PadState.QAM, true);
if (chordButtonPressed) effectiveState.press(chordButton, true);
writer.writePad(effectiveState);
writePad(effectiveState);
}
public static boolean wiredXboxLayout(MotionEvent event) {
@@ -255,6 +255,16 @@ object SessionPrefs {
prefs(context).edit().putBoolean("backActionsInverted", inverted).apply()
}
val BACK_DOUBLE_TAP_MS_OPTIONS = (100..800 step 100).toList()
fun backDoubleTapMs(context: Context): Int =
prefs(context).getInt("backDoubleTapMs", 500).takeIf { it in BACK_DOUBLE_TAP_MS_OPTIONS } ?: 500
fun setBackDoubleTapMs(context: Context, ms: Int) {
require(ms in BACK_DOUBLE_TAP_MS_OPTIONS)
prefs(context).edit().putInt("backDoubleTapMs", ms).apply()
}
const val TOUCH_AUTO = "auto"
const val TOUCH_PAD = "touchpad"
const val TOUCH_DIRECT = "direct"
@@ -14,7 +14,7 @@ object SettingsReset {
"orientation", "launcherFullscreen", "animations", "theme", "runSteamAtStartup", "addedGamesArt",
"gameStoresEnabled", "gameStoresSpeedTier", "gameStoresParallel", "storesOpenTab", "storesShowMature",
"storesInstallLanguage",
"hud", "hudDeck", "hudFps", "hudBattery", "hudGraph", "hudColor", "hudOpacity", "backActionsInverted", "touch", "osc", "shape", "steamController",
"hud", "hudDeck", "hudFps", "hudBattery", "hudGraph", "hudColor", "hudOpacity", "backActionsInverted", "backDoubleTapMs", "touch", "osc", "shape", "steamController",
"gamepadLeft", "gamepadRight", "pipAutoEnter", "unfoldedControls", "unfoldedControlsWithController",
"stretch16x9", "picturePosition",
"gpuDriverMode", "androidDriver", "linuxDriver", "linuxDriver.steam", "linuxDriver.desktop",
@@ -215,6 +215,8 @@ class DrawerActions(
val micEnabled: Boolean = false,
val onMicEnabled: (Boolean) -> Unit = {},
val backActionsInverted: Boolean,
val backDoubleTapMs: Int = 500,
val onBackDoubleTapMs: (Int) -> Unit = {},
val padType: String? = null,
val onPadType: (String) -> Unit = {},
val touchMode: String,
@@ -607,6 +609,9 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
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) ChoiceRow(host, "back-double-tap", stringResource(R.string.back_double_tap_window), null,
SessionPrefs.BACK_DOUBLE_TAP_MS_OPTIONS.map { it to stringResource(R.string.back_tap_ms, it) },
a.backDoubleTapMs, chipModifier = focus.track(page, "back-double-tap"), onPick = a.onBackDoubleTapMs)
if (a.steam && a.unfoldedControls != null) ToggleRow(host, "unfolded-controls", stringResource(R.string.unfolded_controls),
stringResource(R.string.unfolded_controls_hint), a.unfoldedControls,
chipModifier = focus.track(page, "unfolded-controls"), onChange = a.onUnfoldedControls)
+3 -1
View File
@@ -306,7 +306,9 @@
<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">Hold Back for 700 ms, then press another button for Steam shortcuts. Single and double taps keep the selected actions.</string>
<string name="back_native_qam_hint">Hold Back for 700 ms, or hold Back and press another button for Steam shortcuts.</string>
<string name="back_double_tap_window">Double-tap window</string>
<string name="back_tap_ms">%1$d ms</string>
<string name="brightness_permission_title">Allow brightness control</string>
<string name="brightness_permission_text">Tap to allow Steam to change Android screen brightness.</string>
<string name="back_menu_then_qam">Menu → QAM</string>
@@ -40,6 +40,29 @@ class BackQamInputTest {
assertTrue(changes.isEmpty())
}
@Test fun theConfiguredWindowControlsSingleAndDoubleTaps() {
for (window in listOf(100L, 800L)) {
taps.clear()
val configured = BackQamInput(changes::add, taps::add, doubleTapMs = { window })
fun press(action: Int) = configured.onKeyEvent(KeyEvent(action, KeyEvent.KEYCODE_BACK), true)
press(KeyEvent.ACTION_DOWN)
press(KeyEvent.ACTION_UP)
advance(window - 1)
assertTrue(taps.isEmpty())
press(KeyEvent.ACTION_DOWN)
press(KeyEvent.ACTION_UP)
assertEquals(listOf(true), taps)
taps.clear()
press(KeyEvent.ACTION_DOWN)
press(KeyEvent.ACTION_UP)
advance(window - 1)
assertTrue(taps.isEmpty())
advance(1)
assertEquals(listOf(false), taps)
assertTrue(changes.isEmpty())
}
}
@Test fun aHoldStartsAt700msAndNeverProducesATap() {
assertTrue(send(KeyEvent.ACTION_DOWN))
advance(699)
@@ -90,6 +113,55 @@ class BackQamInputTest {
assertEquals(listOf(true, false), changes)
}
@Test fun aChordImmediatelyStartsAHoldAndSuppressesTheTap() {
send(KeyEvent.ACTION_DOWN)
advance(25)
input.onChordInput()
assertEquals(listOf(true), changes)
input.onChordInput()
send(KeyEvent.ACTION_UP)
advance(1000)
assertEquals(listOf(true, false), changes)
assertTrue(taps.isEmpty())
}
@Test fun aChordOnTheSecondPressCancelsBothTapActions() {
send(KeyEvent.ACTION_DOWN)
send(KeyEvent.ACTION_UP)
advance(100)
send(KeyEvent.ACTION_DOWN)
input.onChordInput()
send(KeyEvent.ACTION_UP)
advance(1000)
assertEquals(listOf(true, false), changes)
assertTrue(taps.isEmpty())
}
@Test fun controllerInputAfterBackIsReleasedOrCancelledCannotStartAHold() {
send(KeyEvent.ACTION_DOWN)
send(KeyEvent.ACTION_UP)
input.onChordInput()
advance(500)
assertEquals(listOf(false), taps)
send(KeyEvent.ACTION_DOWN)
input.cancel()
input.onChordInput()
advance(1000)
assertTrue(changes.isEmpty())
}
@Test fun anImmediateChordKeepsAllBackOwnersUntilTheLastRelease() {
send(KeyEvent.ACTION_DOWN, device = 4)
send(KeyEvent.ACTION_DOWN, device = 5)
input.onChordInput()
input.onDeviceRemoved(4)
assertEquals(listOf(true), changes)
send(KeyEvent.ACTION_UP, device = 5)
advance(1000)
assertEquals(listOf(true, false), changes)
assertTrue(taps.isEmpty())
}
@Test fun disabledRoutingPreservesBackNavigation() {
assertFalse(send(KeyEvent.ACTION_DOWN, enabled = false))
assertFalse(send(KeyEvent.ACTION_UP, enabled = false))
@@ -1,6 +1,11 @@
package com.droiddeck.launcher.input
import android.os.Handler
import android.os.Looper
import android.hardware.input.InputManager
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import com.droiddeck.launcher.session.SessionState
import java.nio.file.Files
import java.time.Duration
@@ -10,13 +15,16 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.util.ReflectionHelpers
import org.robolectric.shadows.InputDeviceBuilder
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], manifest = Config.NONE, instrumentedPackages = ["com.droiddeck.launcher.input.FakeInputWriter"],
shadows = [JoyConPadTest.RecordingWriter::class])
@Config(sdk = [34], manifest = Config.NONE, instrumentedPackages = ["com.droiddeck.launcher.input.FakeInputWriter", "com.droiddeck.launcher.wayland.WaylandCompositor"],
shadows = [JoyConPadTest.RecordingWriter::class, PadBridgeRoutingTest.RegisteredKeyEvent::class,
PadBridgeRoutingTest.RegisteredMotionEvent::class, SessionGestureRoutingTest.CompositorShadow::class])
class PadBridgeQamTest {
private val directory = Files.createTempDirectory("qam-pad").toFile()
private lateinit var bridge: PadBridge
@@ -45,6 +53,7 @@ class PadBridgeQamTest {
bridge.applyExternal({}, false)
assertEquals(supported, pad().isDown(PadState.QAM))
bridge.setBackQamPressed(false)
advanceTime()
}
SessionState.deckPad = false
assertFalse(bridge.canHoldQam())
@@ -54,10 +63,12 @@ class PadBridgeQamTest {
bridge.setBackQamPressed(true)
advanceTime()
bridge.applyExternal({ it.press(PadState.X, true); it.press(PadState.SELECT, true) }, true)
advanceTime()
assertTrue(pad().isDown(PadState.QAM))
assertTrue(pad().isDown(PadState.X))
assertTrue(pad().isDown(PadState.SELECT))
bridge.setBackQamPressed(false)
advanceTime()
assertFalse(pad().isDown(PadState.QAM))
assertTrue(pad().isDown(PadState.X))
assertTrue(pad().isDown(PadState.SELECT))
@@ -72,6 +83,7 @@ class PadBridgeQamTest {
bridge.setSystemButtons(false, false)
assertTrue(pad().isDown(PadState.QAM))
bridge.setBackQamPressed(false)
advanceTime()
assertFalse(pad().isDown(PadState.QAM))
}
@@ -106,10 +118,175 @@ class PadBridgeQamTest {
assertFalse(pad().isDown(PadState.QAM))
}
@Test fun aQuickPartnerPressReachesSteamWithQamAndReleasesIndependently() {
val device = controller()
val taps = mutableListOf<Boolean>()
val input = BackQamInput(bridge::setBackQamPressed, taps::add)
bridge.setOnChordInput {
assertFalse(pad().isDown(PadState.X))
input.onChordInput()
}
input.onKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BACK), true)
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(25))
assertTrue(bridge.onKeyEvent(key(device, KeyEvent.KEYCODE_BUTTON_X)))
assertTrue(pad().isDown(PadState.QAM))
assertFalse(pad().isDown(PadState.X))
advance(79)
assertFalse(pad().isDown(PadState.X))
advance(1)
assertTrue(pad().isDown(PadState.X))
input.onKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_BACK), true)
assertFalse(pad().isDown(PadState.QAM))
assertTrue(pad().isDown(PadState.X))
bridge.onKeyEvent(key(device, KeyEvent.KEYCODE_BUTTON_X, KeyEvent.ACTION_UP))
advance(80)
assertFalse(pad().isDown(PadState.X))
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(1))
assertTrue(taps.isEmpty())
}
@Test fun onlyFreshAcceptedPrimaryControllerPressesPromoteAChord() {
val device = controller()
val second = controller(11)
val promoted = mutableListOf<Boolean>()
bridge.setOnChordInput { promoted.add(true) }
bridge.onKeyEvent(key(device, KeyEvent.KEYCODE_BUTTON_X, KeyEvent.ACTION_UP))
bridge.onKeyEvent(key(device, KeyEvent.KEYCODE_BUTTON_X, repeat = 1))
bridge.onKeyEvent(key(device, KeyEvent.KEYCODE_A))
bridge.onKeyEvent(key(second, KeyEvent.KEYCODE_BUTTON_X))
bridge.setExcluded { it == device.id }
bridge.onKeyEvent(key(device, KeyEvent.KEYCODE_BUTTON_X))
assertTrue(promoted.isEmpty())
bridge.setExcluded { false }
for (code in listOf(KeyEvent.KEYCODE_BUTTON_X, KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_BUTTON_L2)) {
bridge.onKeyEvent(key(device, code))
}
assertEquals(3, promoted.size)
}
@Test fun aQuickChordKeepsItsPressAndReleaseAfterTheModifierLead() {
bridge.setBackQamPressed(true)
bridge.applyExternal({ it.press(PadState.X, true) }, true)
advance(20)
bridge.applyExternal({ it.press(PadState.X, false) }, true)
bridge.setBackQamPressed(false)
assertTrue(pad().isDown(PadState.QAM))
assertFalse(pad().isDown(PadState.X))
advance(60)
assertTrue(pad().isDown(PadState.QAM))
assertTrue(pad().isDown(PadState.X))
advance(19)
assertTrue(pad().isDown(PadState.X))
advance(1)
assertFalse(pad().isDown(PadState.X))
assertFalse(pad().isDown(PadState.QAM))
}
@Test fun cancellationDiscardsQueuedPartnersAndTheirReleases() {
val input = BackQamInput(bridge::setBackQamPressed, {}, onCancel = bridge::cancelBackQam)
input.onKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BACK), true)
input.onChordInput()
bridge.applyExternal({ it.press(PadState.LB, true) }, true)
input.onKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_BACK), true)
input.cancel()
advance(1000)
assertFalse(pad().isDown(PadState.LB))
assertFalse(pad().isDown(PadState.QAM))
bridge.applyExternal({ it.press(PadState.X, true) }, true)
input.cancel()
assertTrue(pad().isDown(PadState.X))
}
@Test fun aSettledHoldDoesNotDelayLaterPartnerInput() {
bridge.setBackQamPressed(true)
advance(80)
bridge.applyExternal({ it.press(PadState.LB, true) }, true)
assertTrue(pad().isDown(PadState.LB))
bridge.setBackQamPressed(false)
assertFalse(pad().isDown(PadState.QAM))
}
@Test fun queuedReportsUseTheLiveStickAndStopItImmediatelyOnBackRelease() {
bridge.setBackQamPressed(true)
bridge.applyExternal({ it.press(PadState.X, true); it.leftY = -1f }, true)
advance(20)
bridge.applyExternal({ it.leftY = -0.25f }, true)
advance(60)
assertEquals(-0.25f, pad().leftY)
bridge.setBackQamPressed(false)
assertEquals(0f, pad().leftY)
advance(1000)
assertEquals(0f, pad().leftY)
bridge.applyExternal({ it.leftY = 0f }, true)
bridge.applyExternal({ it.leftY = 0.75f }, true)
assertEquals(0.75f, pad().leftY)
}
@Test fun releasingBackDuringTheLeadNeverReplaysAnOldStickMovement() {
bridge.setBackQamPressed(true)
bridge.applyExternal({ it.leftY = -1f }, true)
advance(20)
bridge.setBackQamPressed(false)
advance(1000)
assertEquals(0f, pad().leftY)
assertFalse(pad().isDown(PadState.QAM))
}
@Test fun theInitialPartnerKeepsItsDurationWhenReleasedAfterTheLead() {
bridge.setBackQamPressed(true)
bridge.applyExternal({ it.press(PadState.X, true) }, true)
advance(100)
bridge.applyExternal({ it.press(PadState.X, false) }, true)
assertTrue(pad().isDown(PadState.X))
advance(79)
assertTrue(pad().isDown(PadState.X))
advance(1)
assertFalse(pad().isDown(PadState.X))
}
@Test fun deliberateStickTriggerAndHatMotionPromoteChordsButNeutralAndDriftDoNot() {
val device = controller()
val promoted = mutableListOf<Boolean>()
bridge.setOnChordInput { promoted.add(true) }
for ((axis, value) in listOf(MotionEvent.AXIS_X to 0f, MotionEvent.AXIS_X to 0.1f,
MotionEvent.AXIS_LTRIGGER to 0f, MotionEvent.AXIS_HAT_Y to 0f)) motion(device, axis, value)
assertTrue(promoted.isEmpty())
for ((axis, value) in listOf(MotionEvent.AXIS_X to -0.15f, MotionEvent.AXIS_LTRIGGER to 0.75f,
MotionEvent.AXIS_HAT_Y to -1f)) motion(device, axis, value)
assertEquals(3, promoted.size)
}
private fun controller(id: Int = 10): InputDevice {
val builder = InputDeviceBuilder.newBuilder().setId(id).setName("QAM pad $id")
.setDescriptor("qam-pad-$id").setExternal(true).setKeyboardType(InputDevice.KEYBOARD_TYPE_NON_ALPHABETIC)
.setSources(InputDevice.SOURCE_GAMEPAD or InputDevice.SOURCE_JOYSTICK)
for (axis in listOf(MotionEvent.AXIS_X, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_HAT_Y)) {
builder.addMotionRange(axis, InputDevice.SOURCE_JOYSTICK, 0f, 1f, 0f, 0f, 0f)
}
return builder.build().also {
shadowOf(RuntimeEnvironment.getApplication().getSystemService(InputManager::class.java)).addInputDevice(it)
bridge.applyExternal({}, false)
}
}
private fun key(device: InputDevice, code: Int, action: Int = KeyEvent.ACTION_DOWN, repeat: Int = 0) =
KeyEvent(0L, 0L, action, code, repeat, 0, device.id, 0, 0, InputDevice.SOURCE_GAMEPAD)
private fun motion(device: InputDevice, axis: Int, value: Float) {
val event = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_MOVE, 1,
arrayOf(MotionEvent.PointerProperties().apply { id = 0 }),
arrayOf(MotionEvent.PointerCoords().apply { setAxisValue(axis, value) }),
0, 0, 1f, 1f, device.id, 0, InputDevice.SOURCE_JOYSTICK, 0)
try { assertTrue(bridge.onMotionEvent(event)) } finally { event.recycle() }
}
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))
advance(2000)
}
private fun advance(ms: Long) = shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(ms))
}
@@ -27,6 +27,7 @@ class SettingsResetTest {
@Test fun settingsGoBackToTheirDefaults() {
SessionPrefs.setTheme(app, "ocean")
SessionPrefs.setBackDoubleTapMs(app, 100)
SessionPrefs.setUnfoldedControlsWithController(app, true)
SessionPrefs.setNoXalia(app, false)
session.edit().putInt("fpsLimit.steam", 30).commit()
@@ -46,6 +47,7 @@ class SettingsResetTest {
assertTrue(SettingsReset.resetAll(app, gameProfiles = false))
assertEquals("graphite", SessionPrefs.theme(app))
assertEquals(500, SessionPrefs.backDoubleTapMs(app))
assertFalse(SessionPrefs.unfoldedControlsWithController(app))
assertTrue(SessionPrefs.noXalia(app))
assertFalse(session.contains("fpsLimit.steam"))