mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 03:21:08 +00:00
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:
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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in new issue
Block a user