diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index f4bb0b6e2..1873b15ec 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -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) } diff --git a/app/src/main/java/com/droiddeck/launcher/input/BackQamInput.kt b/app/src/main/java/com/droiddeck/launcher/input/BackQamInput.kt index 83e1adafb..65f9e5b83 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/BackQamInput.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/BackQamInput.kt @@ -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() @@ -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) + } } } 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 2ed8037af..daecd4844 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java +++ b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java @@ -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 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) { diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 8ffaacd56..92163226f 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -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" diff --git a/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt b/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt index c933229a8..27c97b2ba 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt @@ -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", 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..4e59b7444 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -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) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1399db1b6..1b75df827 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -306,7 +306,9 @@ 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 - Hold Back for 700 ms, then press another button for Steam shortcuts. Single and double taps keep the selected actions. + Hold Back for 700 ms, or hold Back and press another button for Steam shortcuts. + Double-tap window + %1$d ms Allow brightness control Tap to allow Steam to change Android screen brightness. Menu → QAM diff --git a/app/src/test/java/com/droiddeck/launcher/input/BackQamInputTest.kt b/app/src/test/java/com/droiddeck/launcher/input/BackQamInputTest.kt index 8006a495b..5759556ae 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/BackQamInputTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/BackQamInputTest.kt @@ -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)) diff --git a/app/src/test/java/com/droiddeck/launcher/input/PadBridgeQamTest.kt b/app/src/test/java/com/droiddeck/launcher/input/PadBridgeQamTest.kt index 3491076a2..ed8f42dd5 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/PadBridgeQamTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/PadBridgeQamTest.kt @@ -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() + 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() + 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() + 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(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)) } diff --git a/app/src/test/java/com/droiddeck/launcher/session/SettingsResetTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SettingsResetTest.kt index 97b1102a3..c37f59c9c 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SettingsResetTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SettingsResetTest.kt @@ -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"))