diff --git a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt index d648f44e7..df4c58b81 100644 --- a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt @@ -59,7 +59,6 @@ import com.droiddeck.launcher.ui.ModeSettings import com.droiddeck.launcher.ui.ModeSettingsActions import com.droiddeck.launcher.ui.ConfirmDialog import com.droiddeck.launcher.ui.ControllerActions -import com.droiddeck.launcher.ui.ControllerMappingPage import com.droiddeck.launcher.input.ControllerPrefs import com.droiddeck.launcher.input.ControllerEditorActivity import com.droiddeck.launcher.ui.FrontEndScreen @@ -1095,12 +1094,11 @@ class FrontEndActivity : ComponentActivity() { onSteamButton = { on -> ControllerPrefs.setSteamButton(this, on); refreshController() }, onQamButton = { on -> ControllerPrefs.setQamButton(this, on); refreshController() }, onKeyboardButton = { on -> ControllerPrefs.setKeyboardButton(this, on); refreshController() }, - onSelectSteam = { on -> ControllerPrefs.setSelectSteam(this, on); refreshController() }, onJoyConVr = { on -> ControllerPrefs.setJoyConVr(this, on); refreshController() }, onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) }, onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() }, onMapping = { showPerformance = false; showComponents = false; showMapping = true }, - onResetAll = { ControllerPrefs.resetAll(this); refreshController() }, + onResetAll = { ControllerPrefs.resetAll(this); com.droiddeck.launcher.input.InputProfiles.resetAll(this); refreshController() }, ), ), page = page, @@ -1432,13 +1430,7 @@ class FrontEndActivity : ComponentActivity() { @Composable private fun MappingHost() { - val settings = controllerSettings ?: return - ControllerMappingPage( - mapping = settings.mapping, - onPick = { id, target -> ControllerPrefs.setTarget(this, id, target); refreshController() }, - onReset = { ControllerPrefs.resetMapping(this); refreshController() }, - onBack = { showMapping = false }, - ) + com.droiddeck.launcher.ui.InputSettingsPage(onBack = { showMapping = false }) } private fun openComponents(focusContent: Boolean = true, tab: String? = null, gameKey: String? = null) { diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 4f159908b..489a91847 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -72,6 +72,17 @@ import com.droiddeck.launcher.input.KeyboardHost import com.droiddeck.launcher.input.ControllerPrefs import com.droiddeck.launcher.input.SessionClipboard import com.droiddeck.launcher.input.OnScreenControls +import com.droiddeck.launcher.input.InputRouter +import com.droiddeck.launcher.input.InputOutput +import com.droiddeck.launcher.input.InputCommand +import com.droiddeck.launcher.input.InputProfiles +import com.droiddeck.launcher.input.InputProfile +import com.droiddeck.launcher.input.InputAction +import com.droiddeck.launcher.input.InputRole +import com.droiddeck.launcher.input.InputControl +import com.droiddeck.launcher.input.InputMotion +import com.droiddeck.launcher.input.HeldInput +import com.droiddeck.launcher.input.DeckControls import com.droiddeck.launcher.input.PadBridge import com.droiddeck.launcher.input.PadMouse import com.droiddeck.launcher.input.PadState @@ -150,6 +161,32 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } private var unfoldedPanel: com.droiddeck.launcher.ui.DeckControlsPanel? = null private lateinit var sessionRoot: FrameLayout + private var inputRouter: InputRouter? = null + private var physicalInputBindings: com.droiddeck.launcher.input.PhysicalInputBindings? = null + private var inputSettingsOpen by mutableStateOf(false) + @Volatile private var inputUsesLocalActions = false + private var inputProfile = InputProfile.defaults(InputProfile.GAME) + private val inputMotion = InputMotion { inputRouter } + private val heldKeys = HeldInput { code, down -> WaylandCompositor.nativeSendKey(code, if (down) 1 else 0) } + private val heldButtons = HeldInput { code, down -> WaylandCompositor.nativeSendSceneInput(3, code, if (down) 1 else 0) } + private val inputOutput = object : InputOutput { + override fun key(code: Int, down: Boolean) { heldKeys.set(4, code, down) } + override fun button(code: Int, down: Boolean) { heldButtons.set(4, code, down) } + override fun pointer(dx: Float, dy: Float) { WaylandCompositor.sendPointerDelta(dx, dy) } + override fun scroll(steps: Int) { WaylandCompositor.nativeSendSceneInput(4, steps, 0) } + override fun requestMotion() { inputMotion.request() } + override fun command(command: InputCommand) { + uiHandler.post { + if (!resumed || isDestroyed || pipUi || SessionState.suspended) return@post + when (command) { + InputCommand.MENU -> { releasePads(); drawerOpen = true } + InputCommand.KEYBOARD -> if (secondScreenPresentation?.showKeyboard() != true) togglePcKeyboard() + InputCommand.STEAM -> tapSteamButton() + InputCommand.QUICK_ACCESS -> tapQam() + } + } + } + } private val guestKeysDown = mutableSetOf() private lateinit var surfaceView: SurfaceView private lateinit var sessionOverlay: ComposeView @@ -159,7 +196,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var joyConVr: com.droiddeck.launcher.input.JoyConVr? = null private var handCamera: com.droiddeck.launcher.handtracking.HandCamera? = null private var padMotion: com.droiddeck.launcher.input.PadMotion? = null - private var resumed = false + private var resumed by mutableStateOf(false) private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion(); openDeckRing("deck pad changed"); applyUnfoldedControls() } } private fun openDeckRing(why: String) { @@ -431,6 +468,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onInputDeviceRemoved(deviceId: Int) { Log.i(TAG, "input device $deviceId disconnected") joyConVr?.refresh() + physicalInputBindings?.disconnected(deviceId) padBridge?.onDeviceRemoved(deviceId) if (::padMouse.isInitialized) padMouse.reset() refreshControls() @@ -509,6 +547,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { bridge.setSelectSteam(ControllerPrefs.read(this).selectSteam) bridge.setBuiltinPad(SessionState.mode != SessionService.MODE_STEAM || SessionPrefs.builtinPad(this)) padBridge = bridge + inputRouter = InputRouter(inputOutput).also { bridge.setInputRouter(it); DeckControls.setRouter(it); physicalInputBindings = com.droiddeck.launcher.input.PhysicalInputBindings(it) } + refreshInputProfile() 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 -> @@ -521,7 +561,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { joyConVr = vr } SteamControllerBle.listener = { uiHandler.post { updatePadMotion(); refreshControls() } } - steamController = SteamControllerBle(this).also { it.start() } + steamController = SteamControllerBle(this, inputRouter).also { it.start() } padMotion = com.droiddeck.launcher.input.PadMotion(this) { @Suppress("DEPRECATION") (if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0 @@ -639,6 +679,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (pipUi) return when { ::loading.isInitialized && loading.ended -> finish() + inputSettingsOpen -> inputSettingsOpen = false drawerOpen -> drawerOpen = false achievementsOpen -> closeAchievements() pcKeyboardOpen -> pcKeyboardOpen = false @@ -730,12 +771,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } if (nextGame != gestureGame) { gestureGame = nextGame; refreshGestureConfig() } } - LaunchedEffect(drawerOpen, pcKeyboardOpen, achievementsOpen, gestureSettingsOpen, pipUi, SessionState.suspended) { - if (drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || pipUi || SessionState.suspended) { + LaunchedEffect(drawerOpen, pcKeyboardOpen, achievementsOpen, gestureSettingsOpen, inputSettingsOpen, pipUi, SessionState.suspended, resumed) { + padBridge?.setInputEnabled(resumed && !drawerOpen && !pcKeyboardOpen && !achievementsOpen && !gestureSettingsOpen && !inputSettingsOpen && !pipUi && !SessionState.suspended) + if (drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || inputSettingsOpen || pipUi || SessionState.suspended) { cancelTouchGestures() } } com.droiddeck.launcher.ui.GestureFeedback(recognizedGesture) + if (inputSettingsOpen) com.droiddeck.launcher.ui.InputSettingsDialog( + onBack = { inputSettingsOpen = false; secondScreenPresentation?.refreshControls() }, + onChanged = ::refreshInputProfile, + ) if (gestureSettingsOpen) com.droiddeck.launcher.ui.TouchGestureDialog( game = gestureGame, onDismiss = { gestureSettingsOpen = false }, @@ -781,7 +827,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (pcKeyboardOpen || drawerOpen || achievementsOpen) padMouse.reset() } if (pcKeyboardOpen) com.droiddeck.launcher.ui.PcKeyboard( - sendKey = { code, down -> if (CompositorHost.isStarted) sendGuestKey(code, down) }, + sendKey = { code, down -> if (CompositorHost.isStarted) sendGuestKey(code, down, 32) }, onAndroidKeyboard = { pcKeyboardOpen = false; keyboard?.toggle() }, onClose = { pcKeyboardOpen = false }, ) @@ -927,6 +973,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { SessionService.suspendPolicyChanged(this@SessionActivity) }, onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() }, + onInputSettings = { releasePads(); drawerOpen = false; inputSettingsOpen = true }, onGestureSettings = { drawerOpen = false; gestureSettingsOpen = true }, onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() }, onSecondScreenMode = ::selectSecondScreenMode, @@ -1478,7 +1525,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (CompositorHost.isStarted) { WaylandCompositor.nativeSendTouch(3, -1, 0, 0) listOf(PointerGestures.BTN_LEFT, PointerGestures.BTN_RIGHT, PointerGestures.BTN_MIDDLE).forEach { - WaylandCompositor.nativeSendSceneInput(3, it, 0) + heldButtons.set(1, it, false) } } if (nextTouchMode == SessionPrefs.TOUCH_OFF || !usingTouchpad()) cursorVisible = false @@ -1963,6 +2010,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } private fun releasePads() { + inputMotion.stop() + physicalInputBindings?.release() padBridge?.releaseAll() joyConVr?.releaseButtons() } @@ -2038,7 +2087,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (pipUi) return true val pad = com.droiddeck.launcher.input.JoyCon.normalize(event) if (pad !== event) return dispatchKeyEvent(pad) + if (physicalInputBindings?.onKey(event, resumed && !drawerOpen && !pcKeyboardOpen && !achievementsOpen && + !inputSettingsOpen && !gestureSettingsOpen && !SessionState.suspended && !loading.visible) == true) return true val fromController = PadBridge.isControllerKeyEvent(event) + if (fromController && (drawerOpen || pcKeyboardOpen || achievementsOpen || inputSettingsOpen || gestureSettingsOpen || SessionState.suspended)) { + padBridge?.setInputEnabled(false) + padBridge?.onKeyEvent(event) + } if (fromController && event.action == KeyEvent.ACTION_DOWN) { if (drawerOpen && !drawerControllerActive) sessionOverlay.requestFocus() drawerControllerActive = true @@ -2088,7 +2143,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } return true } - if ((pcKeyboardOpen || achievementsOpen) && fromController) return super.dispatchKeyEvent(event) + if ((pcKeyboardOpen || achievementsOpen || inputSettingsOpen) && fromController) return super.dispatchKeyEvent(event) if (joyConVr?.onKeyEvent(event) == true) return true if (fromController && event.keyCode != KeyEvent.KEYCODE_BACK && padMouseMode() && padMouse.onKey(event)) return true if (event.keyCode != KeyEvent.KEYCODE_BACK && padBridge?.onKeyEvent(event) == true) return true @@ -2106,12 +2161,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val shiftEvdev = if (evdev > 0 && plain != 0 && event.deviceId <= 0) 42 else 0 if (evdev <= 0 && event.scanCode > 0) evdev = event.scanCode if (evdev > 0) { + val owner = if (event.deviceId <= 0) 16 else 8 if (down) { - if (shiftEvdev != 0) sendGuestKey(shiftEvdev, true) - sendGuestKey(evdev, true) + if (shiftEvdev != 0) sendGuestKey(shiftEvdev, true, owner) + sendGuestKey(evdev, true, owner) } else { - sendGuestKey(evdev, false) - if (shiftEvdev != 0) sendGuestKey(shiftEvdev, false) + sendGuestKey(evdev, false, owner) + if (shiftEvdev != 0) sendGuestKey(shiftEvdev, false, owner) } return true } @@ -2122,6 +2178,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { if (pipUi) return true + if (PadBridge.isControllerMotionEvent(event) && (drawerOpen || pcKeyboardOpen || achievementsOpen || inputSettingsOpen || gestureSettingsOpen || SessionState.suspended)) { + padBridge?.setInputEnabled(false) + padBridge?.onMotionEvent(event) + } if (drawerOpen && PadBridge.isControllerMotionEvent(event)) { if (!drawerControllerActive) sessionOverlay.requestFocus() drawerControllerActive = true @@ -2133,7 +2193,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { return true } if (drawerDirectionKey != KeyEvent.KEYCODE_UNKNOWN) releaseDrawerDirection() - if ((pcKeyboardOpen || achievementsOpen) && PadBridge.isControllerMotionEvent(event)) return super.dispatchGenericMotionEvent(event) + if ((pcKeyboardOpen || achievementsOpen || inputSettingsOpen) && PadBridge.isControllerMotionEvent(event)) return super.dispatchGenericMotionEvent(event) if (joyConVr?.onMotionEvent(event) == true) return true if (PadBridge.isControllerMotionEvent(event) && padMouseMode() && padMouse.onMotion(event)) return true if (padBridge?.onMotionEvent(event) == true) return true @@ -2249,7 +2309,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } else moveTo(gesturePointerX + dx, gesturePointerY + dy) } override fun button(code: Int, down: Boolean) { - WaylandCompositor.nativeSendSceneInput(3, code, if (down) 1 else 0) + heldButtons.set(16, code, down) } override fun prepareRelativePan() { if (pointerLocked) return @@ -2261,7 +2321,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } override fun key(code: Int, down: Boolean) { if (down) gestureKeysDown.add(code) else gestureKeysDown.remove(code) - if (code !in guestKeysDown) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0) + heldKeys.set(2, code, down) } override fun wheel(steps: Int) { WaylandCompositor.nativeSendSceneInput(4, steps, 0) } override fun showKeyboard() { uiHandler.post { touchscreenGestures.cancel(); togglePcKeyboard() } } @@ -2278,7 +2338,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } override fun onButton(button: Int, pressed: Boolean, x: Float, y: Float) { if (!pointerLocked) movePointer(x, y) - WaylandCompositor.nativeSendSceneInput(3, button, if (pressed) 1 else 0) + heldButtons.set(1, button, pressed) } override fun onWheel(steps: Int) { WaylandCompositor.nativeSendSceneInput(4, steps, 0) } override fun onLongPress() { @@ -2296,7 +2356,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { cursorVisible = false } override fun onButton(button: Int, pressed: Boolean) { - WaylandCompositor.nativeSendSceneInput(3, button, if (pressed) 1 else 0) + heldButtons.set(1, button, pressed) } override fun onScroll(dx: Float, dy: Float) { val rect = drawnRect() ?: return @@ -2336,6 +2396,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun padMouseMode(): Boolean { val now = android.os.SystemClock.uptimeMillis() + if (inputUsesLocalActions) { + if (padMouseOn) padMouse.reset() + padMouseOn = false + return false + } if (now - padMouseCheckedAt < PAD_MOUSE_CHECK_MS) return padMouseOn padMouseCheckedAt = now val on = !pointerLocked && EpicClient.launcherFocused(this) && !EpicClient.playing(this) @@ -2463,7 +2528,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { this, target, sendPointer = ::moveSecondScreenPointer, sendButton = { button, pressed -> - WaylandCompositor.nativeSendSceneInput(3, button, if (pressed) 1 else 0) + heldButtons.set(2, button, pressed) }, sendWheel = { steps -> WaylandCompositor.nativeSendSceneInput(4, steps, 0) }, onSteamMenu = ::tapSteamButton, @@ -2486,12 +2551,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } private fun tapSteamButton() { - if (SessionState.mode == SessionService.MODE_STEAM) { - padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, true) } - Handler(Looper.getMainLooper()).postDelayed({ - padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, false) } - }, 90) - } + if (SessionState.mode == SessionService.MODE_STEAM) padBridge?.triggerSteamMenu() + } + + private fun refreshInputProfile() { + inputProfile = InputProfiles.active(this, loadingMode() == SessionService.MODE_DESKTOP) + inputUsesLocalActions = InputControl.padControls.any { inputProfile.action(InputRole.PHYSICAL, it) !is InputAction.Native } + physicalInputBindings?.configure(inputProfile.keys) + padBridge?.setInputProfile(inputProfile) + DeckControls.refresh() } private fun tapQam() { @@ -2545,7 +2613,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } if (panel == null) { panel = com.droiddeck.launcher.ui.DeckControlsPanel(this, onClose = { chooseUnfoldedControls(false) }, - onSteamMenu = ::tapSteamButton, onQam = ::tapQam, pad = padBridge) + onSteamMenu = ::tapSteamButton, onQam = ::tapQam, pad = padBridge, inputRole = InputRole.PRIMARY) sessionRoot.addView(panel, sessionRoot.indexOfChild(surfaceView) + 1) unfoldedPanel = panel updateOnScreenControls() @@ -2629,7 +2697,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onTouchEvent(event: MotionEvent): Boolean { if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) return onMouse(event) - if (drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || pipUi || SessionState.suspended) return true + if (drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || inputSettingsOpen || pipUi || SessionState.suspended) return true if (routedTouchMode == SessionPrefs.TOUCH_GESTURES) { if (event.actionMasked == MotionEvent.ACTION_DOWN) gesturePointerReady = false return touchscreenGestures.onTouch(event) @@ -2678,7 +2746,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { MotionEvent.ACTION_DOWN -> { WaylandCompositor.nativeSendTouch(3, -1, 0, 0) listOf(PointerGestures.BTN_LEFT, PointerGestures.BTN_RIGHT, PointerGestures.BTN_MIDDLE).forEach { - WaylandCompositor.nativeSendSceneInput(3, it, 0) + heldButtons.set(1, it, false) } sendTouch(0, event.actionIndex) } @@ -2702,7 +2770,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { MotionEvent.BUTTON_TERTIARY -> PointerGestures.BTN_MIDDLE else -> PointerGestures.BTN_LEFT } - WaylandCompositor.nativeSendSceneInput(3, button, if (event.actionMasked == MotionEvent.ACTION_BUTTON_PRESS) 1 else 0) + heldButtons.set(8, button, event.actionMasked == MotionEvent.ACTION_BUTTON_PRESS) } MotionEvent.ACTION_SCROLL -> { val v = event.getAxisValue(MotionEvent.AXIS_VSCROLL) @@ -2745,7 +2813,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { MotionEvent.BUTTON_TERTIARY -> PointerGestures.BTN_MIDDLE else -> PointerGestures.BTN_LEFT } - WaylandCompositor.nativeSendSceneInput(3, button, if (event.actionMasked == MotionEvent.ACTION_BUTTON_PRESS) 1 else 0) + heldButtons.set(8, button, event.actionMasked == MotionEvent.ACTION_BUTTON_PRESS) } MotionEvent.ACTION_SCROLL -> { val v = event.getAxisValue(MotionEvent.AXIS_VSCROLL) @@ -2867,10 +2935,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun keyboardCoversPad() = SessionState.mode == SessionService.MODE_STEAM && AgentGuest.steamKeyboard - private fun sendGuestKey(code: Int, down: Boolean) { + private fun sendGuestKey(code: Int, down: Boolean, owner: Int = 1) { if (down && pipUi) return - if (down) guestKeysDown.add(code) else guestKeysDown.remove(code) - if (down || code !in gestureKeysDown) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0) + if (owner == 1) { if (down) guestKeysDown.add(code) else guestKeysDown.remove(code) } + heldKeys.set(owner, code, down) } // The frozen game cannot draw again, so the paused overlay shows a copy taken while the surface still holds its frame. @@ -2944,6 +3012,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { scrollTouch.cancel() directTouchHold.cancel() guestKeysDown.toList().forEach { sendGuestKey(it, false) } + heldKeys.clear() + heldButtons.clear() keyboard?.hide() sessionClipboard.stop() padMotion?.stop() @@ -2954,7 +3024,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (CompositorHost.isStarted) { WaylandCompositor.nativeSendTouch(3, -1, 0, 0) listOf(PointerGestures.BTN_LEFT, PointerGestures.BTN_RIGHT, PointerGestures.BTN_MIDDLE).forEach { - WaylandCompositor.nativeSendSceneInput(3, it, 0) + heldButtons.set(1, it, false) } WaylandCompositor.nativeSetScaleMode(SCALE_FIT, ALIGN_CENTER) } @@ -3065,6 +3135,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (!pipUi) sessionClipboard.start() updatePadMotion() readPrefs() + refreshInputProfile() refreshControls() if (::pip.isInitialized) pip.resumed() if (CompositorHost.isStarted) applyFrameGen() @@ -3074,12 +3145,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onPause() { if (runtimeRemovalBlocked) { super.onPause(); return } capturePausedFrame() + padBridge?.setInputEnabled(false) sessionClipboard.stop() if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel() if (::touchpad.isInitialized) touchpad.cancel() if (::directTouchHold.isInitialized) directTouchHold.cancel() uiHandler.removeCallbacks(clearRecognizedGesture) gestureSettingsOpen = false + inputSettingsOpen = false + releasePads() + heldKeys.clear() + heldButtons.clear() drawerOpen = false pcKeyboardOpen = false achievementsOpen = false @@ -3518,7 +3594,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { joyConVr = null handCamera?.stop() handCamera = null + inputMotion.stop() + padBridge?.setInputEnabled(false) + physicalInputBindings?.close() padBridge?.stop() + DeckControls.setRouter(null) + inputRouter = null + heldKeys.clear() + heldButtons.clear() steamController?.stop() if (SessionState.padBridge === padBridge) SessionState.padBridge = null padMotion?.stop() diff --git a/app/src/main/java/com/droiddeck/launcher/input/DeckControls.kt b/app/src/main/java/com/droiddeck/launcher/input/DeckControls.kt index a8c499793..36584a5a5 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/DeckControls.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/DeckControls.kt @@ -5,53 +5,139 @@ object DeckControls { const val R4 = 2 const val L5 = 4 const val R5 = 8 - private const val LEFT_TOUCH = 16 - private const val RIGHT_TOUCH = 32 - private const val LEFT_CLICK = 64 - private const val RIGHT_CLICK = 128 - - private const val SLOT = 0 - - private var controls = 0 + private val controls = arrayOf(InputControl.L4, InputControl.R4, InputControl.L5, InputControl.R5, + InputControl.LEFT_PAD, InputControl.RIGHT_PAD, InputControl.LEFT_CLICK, InputControl.RIGHT_CLICK) + private val sources = ArrayList() + private val physical = Source(InputRole.PHYSICAL) + private var router: InputRouter? = null private val pads = ShortArray(4) private val pressure = ShortArray(2) @Synchronized - fun setGrip(bit: Int, down: Boolean) { - set(bit, down) + fun setRouter(next: InputRouter?) { + for (source in sources) source.closeRoutes() + router = next + for (source in sources) source.configure() publish() } @Synchronized - fun setPad(right: Boolean, touching: Boolean, x: Float = 0f, y: Float = 0f) { - val at = if (right) 2 else 0 - set(if (right) RIGHT_TOUCH else LEFT_TOUCH, touching) - pads[at] = if (touching) axis(x) else 0 - pads[at + 1] = if (touching) axis(y) else 0 - publish() - } + fun source(role: InputRole) = Source(role) @Synchronized - fun setClick(right: Boolean, down: Boolean) { - set(if (right) RIGHT_CLICK else LEFT_CLICK, down) - pressure[if (right) 1 else 0] = if (down) Short.MAX_VALUE else 0 - publish() - } + fun setGrip(bit: Int, down: Boolean) = physical.setGrip(bit, down) + + @Synchronized + fun setPad(right: Boolean, touching: Boolean, x: Float = 0f, y: Float = 0f) = physical.setPad(right, touching, x, y) + + @Synchronized + fun setClick(right: Boolean, down: Boolean) = physical.setClick(right, down) @Synchronized fun releaseAll() { - if (controls == 0 && pads.all { it.toInt() == 0 } && pressure.all { it.toInt() == 0 }) return - controls = 0 - pads.fill(0) - pressure.fill(0) + for (source in sources) source.clear() publish() } - private fun set(bit: Int, on: Boolean) { - controls = if (on) controls or bit else controls and bit.inv() + @Synchronized + fun suspend() { + for (source in sources) source.suspend() + publish() } - private fun axis(value: Float): Short = (value.coerceIn(-1f, 1f) * Short.MAX_VALUE).toInt().toShort() + @Synchronized + fun refresh() { + for (source in sources) source.route() + publish() + } - private fun publish() = FakeInputWriter.writeDeckControls(SLOT, controls, pads, pressure) + class Source internal constructor(private val role: InputRole) { + private val x = FloatArray(8) + private val y = FloatArray(8) + private val touched = BooleanArray(8) + private var routes: Array? = null + internal var mask = 0 + internal val axes = ShortArray(4) + + internal fun configure() { + routes = router?.let { r -> Array(8) { r.channel(role, controls[it]) } } + } + + private fun attach() { + if (this !in sources) { sources.add(this); configure() } + } + + fun setGrip(bit: Int, down: Boolean) = synchronized(DeckControls) { + val at = when (bit) { L4 -> 0; R4 -> 1; L5 -> 2; R5 -> 3; else -> return@synchronized } + update(at, if (down) 1f else 0f) + } + + fun setPad(right: Boolean, touching: Boolean, x: Float = 0f, y: Float = 0f) = synchronized(DeckControls) { + update(if (right) 5 else 4, if (touching) x else 0f, if (touching) y else 0f, touching) + } + + fun setClick(right: Boolean, down: Boolean) = synchronized(DeckControls) { + update(if (right) 7 else 6, if (down) 1f else 0f) + } + + private fun update(at: Int, value: Float, second: Float = 0f, contact: Boolean = false) { + val timing = InputTiming.begin() + attach() + x[at] = value; y[at] = second; touched[at] = contact + route() + publish() + InputTiming.end(timing) + } + + internal fun route() { + mask = 0 + axes.fill(0) + for (i in controls.indices) { + val target = if (routes == null) controls[i] else routes!![i].update(x[i], y[i], touched[i]) ?: continue + val at = controls.indexOf(target) + if (at < 0) continue + if (at == 4 || at == 5) { + if (touched[i]) { + mask = mask or (1 shl at) + axes[(at - 4) * 2] = axis(x[i]); axes[(at - 4) * 2 + 1] = axis(y[i]) + } + } else if (x[i] > 0.5f) mask = mask or (1 shl at) + } + } + + internal fun clear() { + x.fill(0f); y.fill(0f); touched.fill(false); mask = 0; axes.fill(0) + routes?.forEach { it.release() } + } + + internal fun suspend() { + mask = 0; axes.fill(0) + routes?.forEach { it.suspend() } + } + + fun release() = synchronized(DeckControls) { clear(); publish() } + + internal fun closeRoutes() { routes?.forEach { it.close() }; routes = null } + + fun close() = synchronized(DeckControls) { + clear(); closeRoutes(); sources.remove(this); publish() + } + } + + private fun axis(value: Float) = (value.coerceIn(-1f, 1f) * Short.MAX_VALUE).toInt().toShort() + + private fun publish() { + var mask = 0 + pads.fill(0) + for (source in sources) { + mask = mask or source.mask + for (side in 0..1) if (source.mask and (16 shl side) != 0) { + pads[side * 2] = source.axes[side * 2] + pads[side * 2 + 1] = source.axes[side * 2 + 1] + } + } + pressure[0] = if (mask and 64 != 0) Short.MAX_VALUE else 0 + pressure[1] = if (mask and 128 != 0) Short.MAX_VALUE else 0 + FakeInputWriter.writeDeckControls(0, mask, pads, pressure) + } } diff --git a/app/src/main/java/com/droiddeck/launcher/input/HeldInput.kt b/app/src/main/java/com/droiddeck/launcher/input/HeldInput.kt new file mode 100644 index 000000000..1fdb6121f --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/HeldInput.kt @@ -0,0 +1,27 @@ +package com.droiddeck.launcher.input + +class HeldInput(private val emit: (Int, Boolean) -> Unit) { + private val owners = IntArray(768) + + @Synchronized + fun set(source: Int, code: Int, down: Boolean) { + if (code !in owners.indices) return + val before = owners[code] + val after = if (down) before or source else before and source.inv() + owners[code] = after + if ((before == 0) != (after == 0)) emit(code, after != 0) + } + + @Synchronized + fun release(source: Int) { + for (code in owners.indices) if (owners[code] and source != 0) set(source, code, false) + } + + @Synchronized + fun clear() { + for (code in owners.indices) if (owners[code] != 0) { + owners[code] = 0 + emit(code, false) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/InputMotion.kt b/app/src/main/java/com/droiddeck/launcher/input/InputMotion.kt new file mode 100644 index 000000000..9d24ca8d5 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/InputMotion.kt @@ -0,0 +1,35 @@ +package com.droiddeck.launcher.input + +import android.os.Handler +import android.os.Looper +import android.view.Choreographer + +class InputMotion(private val router: () -> InputRouter?) : Choreographer.FrameCallback { + private val handler = Handler(Looper.getMainLooper()) + private var scheduled = false + private var lastFrame = 0L + private val start = Runnable { + if (!scheduled) { + scheduled = true + lastFrame = System.nanoTime() + Choreographer.getInstance().postFrameCallback(this) + } + } + + fun request() { + if (Looper.myLooper() == Looper.getMainLooper()) start.run() else handler.post(start) + } + + override fun doFrame(frameTimeNanos: Long) { + val dt = (frameTimeNanos - lastFrame).coerceAtLeast(0) / 1_000_000_000f + lastFrame = frameTimeNanos + if (router()?.tick(dt) == true) Choreographer.getInstance().postFrameCallback(this) + else scheduled = false + } + + fun stop() { + handler.removeCallbacks(start) + Choreographer.getInstance().removeFrameCallback(this) + scheduled = false + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/InputProfile.kt b/app/src/main/java/com/droiddeck/launcher/input/InputProfile.kt new file mode 100644 index 000000000..9ce1c1e07 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/InputProfile.kt @@ -0,0 +1,81 @@ +package com.droiddeck.launcher.input + +enum class InputRole { PHYSICAL, PRIMARY, SECONDARY } + +enum class InputControl(val id: String, val label: String, val dimensions: Int = 1) { + A("a", "A"), B("b", "B"), X("x", "X"), Y("y", "Y"), + LB("lb", "L1"), RB("rb", "R1"), SELECT("select", "View / Select"), START("start", "Menu / Start"), + L3("l3", "L3"), R3("r3", "R3"), GUIDE("guide", "Steam / Guide"), QAM("qam", "Quick access"), + UP("up", "D-pad up"), RIGHT("right", "D-pad right"), DOWN("down", "D-pad down"), LEFT("left", "D-pad left"), + LT("lt", "L2"), RT("rt", "R2"), LS("ls", "Left stick", 2), RS("rs", "Right stick", 2), + L4("l4", "L4"), R4("r4", "R4"), L5("l5", "L5"), R5("r5", "R5"), + LEFT_PAD("left-pad", "Left trackpad", 2), RIGHT_PAD("right-pad", "Right trackpad", 2), + LEFT_CLICK("left-click", "Left trackpad click"), RIGHT_CLICK("right-click", "Right trackpad click"); + + val isPad: Boolean get() = ordinal <= RS.ordinal + val isTrackpad: Boolean get() = this == LEFT_PAD || this == RIGHT_PAD + + companion object { + val padControls = entries.filter { it.isPad }.toTypedArray() + fun fromId(id: String) = entries.firstOrNull { it.id == id } + } +} + +enum class InputCommand { MENU, KEYBOARD, STEAM, QUICK_ACCESS } + +sealed interface InputAction { + data class Native(val target: InputControl? = null) : InputAction + data object Disabled : InputAction + data class Keys(val codes: List) : InputAction + data class MouseButton(val code: Int) : InputAction + data class Command(val command: InputCommand) : InputAction + data class Directions(val up: Int, val right: Int, val down: Int, val left: Int) : InputAction + data class Pointer(val speed: Float = 900f) : InputAction + data class Scroll(val speed: Float = 10f) : InputAction +} + +data class InputProfile( + val id: String, + val bindings: Map> = emptyMap(), + val keys: List = emptyList(), +) { + fun action(role: InputRole, control: InputControl): InputAction = + bindings[role]?.get(control) ?: InputAction.Native() + + fun withBinding(role: InputRole, control: InputControl, action: InputAction) = + copy(bindings = bindings + (role to (bindings[role].orEmpty() + (control to action)))) + + companion object { + const val GAME = "game" + const val DESKTOP = "desktop" + + fun defaults(id: String): InputProfile { + if (id != DESKTOP) return InputProfile(GAME) + val desktop = mapOf( + InputControl.LS to InputAction.Pointer(), InputControl.RS to InputAction.Scroll(), + InputControl.A to InputAction.MouseButton(272), InputControl.B to InputAction.MouseButton(273), + InputControl.X to InputAction.Keys(listOf(28)), InputControl.Y to InputAction.Keys(listOf(1)), + InputControl.UP to InputAction.Keys(listOf(103)), InputControl.RIGHT to InputAction.Keys(listOf(106)), + InputControl.DOWN to InputAction.Keys(listOf(108)), InputControl.LEFT to InputAction.Keys(listOf(105)), + InputControl.LT to InputAction.MouseButton(273), InputControl.RT to InputAction.MouseButton(272), + InputControl.LB to InputAction.Keys(listOf(29)), InputControl.RB to InputAction.Keys(listOf(56)), + InputControl.SELECT to InputAction.Command(InputCommand.KEYBOARD), + InputControl.START to InputAction.Command(InputCommand.MENU), + InputControl.LEFT_PAD to InputAction.Scroll(), InputControl.RIGHT_PAD to InputAction.Pointer(), + InputControl.LEFT_CLICK to InputAction.MouseButton(273), InputControl.RIGHT_CLICK to InputAction.MouseButton(272), + ) + return InputProfile(DESKTOP, InputRole.entries.associateWith { desktop }) + } + } +} + +internal fun InputAction.validFor(control: InputControl): Boolean = when (this) { + is InputAction.Native -> target == null || (control.isPad == target.isPad && control.dimensions == target.dimensions && + (!control.isTrackpad && !target.isTrackpad || control.isTrackpad && target.isTrackpad)) + is InputAction.Keys -> codes.isNotEmpty() && codes.size <= 8 && codes.distinct().size == codes.size && codes.all { it in 1..255 } + is InputAction.MouseButton -> code in 272..276 + is InputAction.Directions -> control.dimensions == 2 && listOf(up, right, down, left).all { it in 1..255 } + is InputAction.Pointer -> control.dimensions == 2 && speed.isFinite() && speed in 100f..3000f + is InputAction.Scroll -> control.dimensions == 2 && speed.isFinite() && speed in 1f..60f + else -> true +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/InputProfiles.kt b/app/src/main/java/com/droiddeck/launcher/input/InputProfiles.kt new file mode 100644 index 000000000..71a308de2 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/InputProfiles.kt @@ -0,0 +1,115 @@ +package com.droiddeck.launcher.input + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject + +object InputProfiles { + const val AUTO = "auto" + private const val VERSION = 1 + + private fun prefs(context: Context) = context.getSharedPreferences("input-profiles", Context.MODE_PRIVATE) + + fun selection(context: Context): String = prefs(context).getString("selected", AUTO) + ?.takeIf { it in listOf(AUTO, InputProfile.GAME, InputProfile.DESKTOP) } ?: AUTO + + fun select(context: Context, id: String) { + require(id in listOf(AUTO, InputProfile.GAME, InputProfile.DESKTOP)) + prefs(context).edit().putString("selected", id).apply() + } + + fun active(context: Context, desktop: Boolean): InputProfile { + val selected = selection(context) + return read(context, if (selected != AUTO) selected else if (desktop) InputProfile.DESKTOP else InputProfile.GAME) + } + + fun read(context: Context, id: String): InputProfile { + migrate(context) + var result = InputProfile.defaults(id) + val stored = prefs(context).getString("profile.$id", null) ?: return result + val document = runCatching { JSONObject(stored) }.getOrNull() ?: return result + if (document.optInt("version") != VERSION) return result + val bindings = document.optJSONArray("bindings") ?: return result + for (i in 0 until minOf(bindings.length(), 256)) { + val entry = bindings.optJSONObject(i) ?: continue + val role = runCatching { InputRole.valueOf(entry.getString("role")) }.getOrNull() ?: continue + val control = InputControl.fromId(entry.optString("control")) ?: continue + val action = decode(entry.optJSONObject("action") ?: continue) ?: continue + if (action.validFor(control)) result = result.withBinding(role, control, action) + } + val keys = document.optJSONArray("keys") + if (keys != null) result = result.copy(keys = (0 until minOf(keys.length(), 64)).mapNotNull { i -> + runCatching { + val item = keys.getJSONObject(i) + val action = decode(item.getJSONObject("action")) ?: return@mapNotNull null + if (!action.validFor(InputControl.A) || action is InputAction.Native && action.target != null) return@mapNotNull null + val key = PhysicalInputKey(item.getString("device"), item.getString("name"), item.getInt("keyCode"), item.getInt("scanCode")) + if (key.device.isBlank() || key.keyCode < 0 || key.scanCode < 0) return@mapNotNull null + PhysicalInputBinding(key, action) + }.getOrNull() + }.distinctBy { Triple(it.key.device, it.key.keyCode, it.key.scanCode) }) + return result + } + + fun save(context: Context, profile: InputProfile) { + val bindings = JSONArray() + for ((role, controls) in profile.bindings) for ((control, action) in controls) { + require(action.validFor(control)) + bindings.put(JSONObject().put("role", role.name).put("control", control.id).put("action", encode(action))) + } + val keys = JSONArray() + for (binding in profile.keys) keys.put(JSONObject().put("device", binding.key.device).put("name", binding.key.name) + .put("keyCode", binding.key.keyCode).put("scanCode", binding.key.scanCode).put("action", encode(binding.action))) + prefs(context).edit().putString("profile.${profile.id}", JSONObject() + .put("version", VERSION).put("id", profile.id).put("bindings", bindings).put("keys", keys).toString()).apply() + } + + fun reset(context: Context, id: String) { prefs(context).edit().remove("profile.$id").apply() } + + fun resetAll(context: Context) { + prefs(context).edit().clear().putBoolean("migrated", true).apply() + } + + private fun migrate(context: Context) { + val p = prefs(context) + if (p.getBoolean("migrated", false)) return + var game = InputProfile.defaults(InputProfile.GAME) + val old = ControllerPrefs.read(context) + for ((id, target) in old.mapping) { + val source = InputControl.fromId(id) ?: continue + val action = if (target == ControllerPrefs.OFF) InputAction.Disabled + else InputAction.Native(InputControl.fromId(target) ?: continue) + if (target != id) game = game.withBinding(InputRole.PRIMARY, source, action) + } + if (old.selectSteam) game = game.withBinding(InputRole.PHYSICAL, InputControl.SELECT, InputAction.Native(InputControl.GUIDE)) + if (!p.contains("profile.${InputProfile.GAME}")) save(context, game) + p.edit().putBoolean("migrated", true).apply() + } + + private fun encode(action: InputAction): JSONObject = JSONObject().apply { + when (action) { + is InputAction.Native -> { put("type", "native"); action.target?.let { put("target", it.id) } } + InputAction.Disabled -> put("type", "disabled") + is InputAction.Keys -> { put("type", "keys"); put("codes", JSONArray(action.codes)) } + is InputAction.MouseButton -> { put("type", "button"); put("code", action.code) } + is InputAction.Command -> { put("type", "command"); put("command", action.command.name) } + is InputAction.Directions -> { put("type", "directions"); put("codes", JSONArray(listOf(action.up, action.right, action.down, action.left))) } + is InputAction.Pointer -> { put("type", "pointer"); put("speed", action.speed.toDouble()) } + is InputAction.Scroll -> { put("type", "scroll"); put("speed", action.speed.toDouble()) } + } + } + + private fun decode(json: JSONObject): InputAction? = runCatching { + when (json.getString("type")) { + "native" -> InputAction.Native(if (json.has("target")) InputControl.fromId(json.getString("target")) ?: return null else null) + "disabled" -> InputAction.Disabled + "keys" -> InputAction.Keys(json.getJSONArray("codes").let { a -> (0 until a.length()).map { a.getInt(it) } }) + "button" -> InputAction.MouseButton(json.getInt("code")) + "command" -> InputAction.Command(InputCommand.valueOf(json.getString("command"))) + "directions" -> json.getJSONArray("codes").let { InputAction.Directions(it.getInt(0), it.getInt(1), it.getInt(2), it.getInt(3)) } + "pointer" -> InputAction.Pointer(json.getDouble("speed").toFloat()) + "scroll" -> InputAction.Scroll(json.getDouble("speed").toFloat()) + else -> null + } + }.getOrNull() +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/InputRouter.kt b/app/src/main/java/com/droiddeck/launcher/input/InputRouter.kt new file mode 100644 index 000000000..921ca05d8 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/InputRouter.kt @@ -0,0 +1,305 @@ +package com.droiddeck.launcher.input + +import kotlin.math.abs + +interface InputOutput { + fun key(code: Int, down: Boolean) + fun button(code: Int, down: Boolean) + fun pointer(dx: Float, dy: Float) + fun scroll(steps: Int) + fun command(command: InputCommand) + fun requestMotion() +} + +class InputRouter(private val output: InputOutput) { + private val keys = IntArray(256) + private val buttons = IntArray(5) + private val channels = ArrayList() + private val pads = ArrayList() + private var profile = InputProfile.defaults(InputProfile.GAME) + private var moving = false + private var wheel = 0f + private var enabled = true + private val snapshots = ArrayList<() -> Unit>() + + @Synchronized + fun beforeProfileChange(snapshot: () -> Unit) { snapshots.add(snapshot) } + + @Synchronized + fun removeSnapshot(snapshot: () -> Unit) { snapshots.remove(snapshot) } + + @Synchronized + fun setEnabled(on: Boolean) { + if (enabled == on) return + if (!on) releaseAll() + enabled = on + for (pad in pads) pad.refreshIdentity() + } + + @Synchronized + fun setProfile(next: InputProfile) { + if (profile == next) return + for (snapshot in snapshots) snapshot() + for (pad in pads) pad.snapshot() + profile = next + for (channel in channels) channel.configure(next.action(channel.role, channel.control)) + for (pad in pads) pad.refreshIdentity() + wheel = 0f + moving = false + } + + @Synchronized + fun pad(role: InputRole): PadSource = PadSource(role).also { pads.add(it) } + + @Synchronized + fun channel(role: InputRole, control: InputControl, fixedAction: InputAction? = null): Channel = Channel(role, control, fixedAction).also { + channels.add(it) + it.configure(profile.action(role, control)) + } + + @Synchronized + fun releaseAll() { + for (snapshot in snapshots) snapshot() + for (pad in pads) pad.snapshot() + for (channel in channels) channel.suspend() + for (pad in pads) pad.refreshIdentity() + wheel = 0f + moving = false + } + + @Synchronized + fun tick(seconds: Float): Boolean { + if (!enabled) return false + var x = 0f + var y = 0f + var scroll = 0f + for (channel in channels) { + if (channel.blocked || channel.control.isTrackpad) continue + val cx = if (abs(channel.x) > 0.12f) channel.x else 0f + val cy = if (abs(channel.y) > 0.12f) channel.y else 0f + when (val action = channel.action) { + is InputAction.Pointer -> { x += cx * action.speed; y += cy * action.speed } + is InputAction.Scroll -> scroll -= cy * action.speed + else -> Unit + } + } + val dt = seconds.coerceIn(0f, 0.05f) + if (x != 0f || y != 0f) output.pointer(x * dt, y * dt) + wheel += scroll * dt + flushWheel() + moving = x != 0f || y != 0f || scroll != 0f + return moving + } + + private fun flushWheel() { + val steps = wheel.toInt() + if (steps != 0) { wheel -= steps; output.scroll(steps) } + } + + private fun key(code: Int, down: Boolean) { + val was = keys[code] + keys[code] = (was + if (down) 1 else -1).coerceAtLeast(0) + if ((was == 0) != (keys[code] == 0)) output.key(code, down) + } + + private fun button(code: Int, down: Boolean) { + val at = code - 272 + val was = buttons[at] + buttons[at] = (was + if (down) 1 else -1).coerceAtLeast(0) + if ((was == 0) != (buttons[at] == 0)) output.button(code, down) + } + + inner class Channel internal constructor(val role: InputRole, val control: InputControl, private val fixedAction: InputAction?) { + var action: InputAction = InputAction.Native() + private set + internal var x = 0f + internal var y = 0f + internal var contact = false + internal var blocked = false + private var held = false + private val directions = BooleanArray(4) + + internal fun configure(next: InputAction) { + suspend() + action = (fixedAction ?: next).takeIf { it.validFor(control) } ?: InputAction.Native() + } + + internal fun observe(value: Float, second: Float = 0f, touching: Boolean = false) { + x = value; y = second; contact = touching + } + + internal fun nativeIdentity(): Boolean = enabled && !blocked && (action as? InputAction.Native)?.target == null && action is InputAction.Native + + internal fun suspend() { + releaseOutputs() + blocked = contact || abs(x) > 0.12f || abs(y) > 0.12f + } + + fun release() = synchronized(this@InputRouter) { + releaseOutputs() + x = 0f; y = 0f; contact = false; blocked = false + } + + fun close() = synchronized(this@InputRouter) { + release() + channels.remove(this) + } + + fun update(value: Float, second: Float = 0f, touching: Boolean = false): InputControl? = synchronized(this@InputRouter) { + val oldX = x + val oldY = y + val oldContact = contact + x = value.coerceIn(-1f, 1f) + y = second.coerceIn(-1f, 1f) + contact = touching + if (!enabled) { + blocked = contact || abs(x) > 0.12f || abs(y) > 0.12f + return@synchronized null + } + if (blocked) { + if (!contact && abs(x) <= 0.12f && abs(y) <= 0.12f) blocked = false + return@synchronized null + } + when (val a = action) { + is InputAction.Native -> return@synchronized a.target ?: control + is InputAction.Disabled -> Unit + is InputAction.Pointer -> { + if (control.isTrackpad) { + if (contact && oldContact) output.pointer((x - oldX) * a.speed, -(y - oldY) * a.speed) + } else requestMotion() + } + is InputAction.Scroll -> { + if (control.isTrackpad) { + if (contact && oldContact) { wheel += (y - oldY) * a.speed; flushWheel() } + } else requestMotion() + } + is InputAction.Directions -> { + val vertical = if (control.isTrackpad) -y else y + direction(0, -vertical, a.up); direction(1, x, a.right) + direction(2, vertical, a.down); direction(3, -x, a.left) + } + else -> { + val down = if (control.isTrackpad) contact else maxOf(abs(x), abs(y)) > if (held) 0.35f else 0.55f + if (held != down) { + held = down + when (a) { + is InputAction.Keys -> if (down) a.codes.forEach { key(it, true) } else a.codes.asReversed().forEach { key(it, false) } + is InputAction.MouseButton -> button(a.code, down) + is InputAction.Command -> if (down) output.command(a.command) + else -> Unit + } + } + } + } + null + } + + private fun requestMotion() { + if (!moving && (abs(x) > 0.12f || abs(y) > 0.12f)) { moving = true; output.requestMotion() } + } + + private fun direction(at: Int, value: Float, code: Int) { + val down = value > if (directions[at]) 0.35f else 0.55f + if (directions[at] != down) { directions[at] = down; key(code, down) } + } + + private fun releaseOutputs() { + when (val a = action) { + is InputAction.Keys -> if (held) a.codes.asReversed().forEach { key(it, false) } + is InputAction.MouseButton -> if (held) button(a.code, false) + is InputAction.Directions -> { + val codes = intArrayOf(a.up, a.right, a.down, a.left) + for (i in directions.indices) if (directions[i]) key(codes[i], false) + } + else -> Unit + } + held = false + directions.fill(false) + } + } + + inner class PadSource internal constructor(role: InputRole) { + private val routes = Array(InputControl.padControls.size) { channel(role, InputControl.padControls[it]) } + private val result = PadState() + private var last: PadState? = null + private var identity = true + private var nativeCandidate = true + + init { refreshIdentity() } + + internal fun refreshIdentity() { + nativeCandidate = routes.all { (it.action as? InputAction.Native)?.target == null && it.action is InputAction.Native } + identity = enabled && nativeCandidate && routes.none { it.blocked } + } + + internal fun snapshot() { + val raw = last ?: return + for (route in routes) { + route.x = raw.value(route.control) + route.y = raw.secondValue(route.control) + } + } + + fun route(raw: PadState): PadState = synchronized(this@InputRouter) { + last = raw + if (identity) return@synchronized raw + result.clear() + for (route in routes) { + val x = raw.value(route.control) + val y = raw.secondValue(route.control) + val target = route.update(x, y) ?: continue + result.add(target, x, y) + } + if (nativeCandidate && enabled && routes.none { it.blocked }) identity = true + result + } + + fun release() = synchronized(this@InputRouter) { + for (route in routes) route.release() + last = null + result.clear() + refreshIdentity() + } + + fun close() = synchronized(this@InputRouter) { + release() + for (route in routes) route.close() + pads.remove(this) + } + } +} + +private val padButtons = intArrayOf(PadState.A, PadState.B, PadState.X, PadState.Y, PadState.LB, PadState.RB, + PadState.SELECT, PadState.START, PadState.L3, PadState.R3, PadState.GUIDE, PadState.QAM) + +internal fun PadState.value(control: InputControl): Float = when (control) { + InputControl.UP -> if (up) 1f else 0f + InputControl.RIGHT -> if (right) 1f else 0f + InputControl.DOWN -> if (down) 1f else 0f + InputControl.LEFT -> if (left) 1f else 0f + InputControl.LT -> leftTrigger + InputControl.RT -> rightTrigger + InputControl.LS -> leftX + InputControl.RS -> rightX + else -> if (control.ordinal < padButtons.size && isDown(padButtons[control.ordinal])) 1f else 0f +} + +internal fun PadState.secondValue(control: InputControl): Float = when (control) { + InputControl.LS -> leftY + InputControl.RS -> rightY + else -> 0f +} + +internal fun PadState.add(control: InputControl, x: Float, y: Float = 0f) { + when (control) { + InputControl.UP -> up = up || x > 0.5f + InputControl.RIGHT -> right = right || x > 0.5f + InputControl.DOWN -> down = down || x > 0.5f + InputControl.LEFT -> left = left || x > 0.5f + InputControl.LT -> leftTrigger = maxOf(leftTrigger, x) + InputControl.RT -> rightTrigger = maxOf(rightTrigger, x) + InputControl.LS -> if (x * x + y * y > leftX * leftX + leftY * leftY) { leftX = x; leftY = y } + InputControl.RS -> if (x * x + y * y > rightX * rightX + rightY * rightY) { rightX = x; rightY = y } + else -> if (control.ordinal < padButtons.size && x > 0.5f) press(padButtons[control.ordinal], true) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/InputSurfaceLayout.kt b/app/src/main/java/com/droiddeck/launcher/input/InputSurfaceLayout.kt new file mode 100644 index 000000000..63b4194f8 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/InputSurfaceLayout.kt @@ -0,0 +1,49 @@ +package com.droiddeck.launcher.input + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject + +data class InputSurfaceElement(val control: InputControl, val x: Float, val y: Float, val width: Float, val height: Float) { + fun bounded() = copy(x = x.coerceIn(0f, 0.9f), y = y.coerceIn(0f, 0.9f), + width = width.coerceIn(0.1f, 1f - x.coerceIn(0f, 0.9f)), height = height.coerceIn(0.1f, 1f - y.coerceIn(0f, 0.9f))) +} + +object InputSurfaceLayout { + private fun prefs(context: Context) = context.getSharedPreferences("input-surfaces", Context.MODE_PRIVATE) + + fun defaults() = listOf( + InputSurfaceElement(InputControl.LEFT_PAD, 0.02f, 0.02f, 0.46f, 0.65f), + InputSurfaceElement(InputControl.RIGHT_PAD, 0.52f, 0.02f, 0.46f, 0.65f), + InputSurfaceElement(InputControl.A, 0.02f, 0.72f, 0.20f, 0.25f), + InputSurfaceElement(InputControl.B, 0.26f, 0.72f, 0.20f, 0.25f), + InputSurfaceElement(InputControl.SELECT, 0.52f, 0.72f, 0.20f, 0.25f), + InputSurfaceElement(InputControl.START, 0.76f, 0.72f, 0.22f, 0.25f), + ) + + fun read(context: Context, profile: String): List { + val text = prefs(context).getString(profile, null) ?: return defaults() + return runCatching { + val doc = JSONObject(text) + require(doc.getInt("version") == 1) + val elements = doc.getJSONArray("elements") + (0 until minOf(elements.length(), 28)).mapNotNull { i -> + val item = elements.optJSONObject(i) ?: return@mapNotNull null + val control = InputControl.fromId(item.optString("control")) ?: return@mapNotNull null + val values = listOf("x", "y", "w", "h").map { item.optDouble(it, Double.NaN).toFloat() } + if (values.any { !it.isFinite() }) return@mapNotNull null + InputSurfaceElement(control, values[0], values[1], values[2], values[3]).bounded() + }.distinctBy { it.control } + }.getOrElse { defaults() } + } + + fun save(context: Context, profile: String, layout: List) { + val array = JSONArray() + for (element in layout.distinctBy { it.control }.take(28)) { + val e = element.bounded() + array.put(JSONObject().put("control", e.control.id).put("x", e.x.toDouble()).put("y", e.y.toDouble()) + .put("w", e.width.toDouble()).put("h", e.height.toDouble())) + } + prefs(context).edit().putString(profile, JSONObject().put("version", 1).put("elements", array).toString()).apply() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/InputTiming.kt b/app/src/main/java/com/droiddeck/launcher/input/InputTiming.kt new file mode 100644 index 000000000..0d2589c42 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/InputTiming.kt @@ -0,0 +1,33 @@ +package com.droiddeck.launcher.input + +import java.util.Locale + +object InputTiming { + @JvmField @Volatile var enabled = false + private val samples = LongArray(4096) + private var count = 0L + private var next = 0 + + @JvmStatic fun begin(): Long = if (enabled) System.nanoTime() else 0L + + @JvmStatic fun end(start: Long) { + if (start == 0L) return + val elapsed = System.nanoTime() - start + synchronized(this) { + samples[next] = elapsed + next = (next + 1) % samples.size + count++ + } + } + + @Synchronized fun reset() { count = 0; next = 0; samples.fill(0) } + + @Synchronized fun report(): String { + val size = minOf(count, samples.size.toLong()).toInt() + if (size == 0) return "No input samples" + val values = samples.copyOf(size).sortedArray() + fun percentile(p: Double) = values[((size - 1) * p).toInt()] / 1000.0 + return String.format(Locale.ROOT, "Dispatch to publication (µs): p50 %.1f, p95 %.1f, p99 %.1f; %d samples, last %d retained", + percentile(.5), percentile(.95), percentile(.99), count, size) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt b/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt index 50ffb17a8..f16bee956 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt @@ -156,7 +156,7 @@ class OnScreenControls( } private fun applySettings() { - for (control in controls) control.target = settings.mapping[control.id] ?: control.id + for (control in controls) control.target = if (pad?.hasInputRouter() == true) control.id else settings.mapping[control.id] ?: control.id val tint = settings.tint val alpha = settings.opacity / 100f fun shade(a: Int, f: Float) = Color.argb((a * alpha).toInt(), (Color.red(tint) * f).toInt(), (Color.green(tint) * f).toInt(), (Color.blue(tint) * f).toInt()) @@ -210,6 +210,7 @@ class OnScreenControls( override fun onDetachedFromWindow() { releaseAll() + pad?.releaseTouch(this) super.onDetachedFromWindow() } @@ -673,12 +674,27 @@ class OnScreenControls( private fun apply() { val bridge = pad if (bridge == null || editing) { invalidate(); return } + if (bridge.hasInputRouter()) { + bridge.applyTouch(this, InputRole.PRIMARY) { state -> + state.clear() + for (control in controls) { + if (!isVisible(control)) continue + if (control.stick >= 0) { + if (control.stick == 0) { state.leftX = control.kx / control.radius; state.leftY = control.ky / control.radius } + else { state.rightX = control.kx / control.radius; state.rightY = control.ky / control.radius } + if (settings.stickClick) state.press(if (control.stick == 0) PadState.L3 else PadState.R3, control.clicked) + } else write(state, control.id, control.pressedBy != -1) + } + } + invalidate() + return + } val qamHeld = controls.any { it.id == "qam" && it.pressedBy != -1 } if (buttonsOnly) { bridge.setSystemButtons(controls.any { it.id == "guide" && it.pressedBy != -1 }, qamHeld) } else { bridge.setSystemButtons(false, qamHeld) - bridge.applyTouch { state -> + bridge.applyTouch(this, InputRole.PRIMARY) { state -> val used = HashSet() val held = HashSet() for (control in controls) { @@ -718,6 +734,7 @@ class OnScreenControls( "l3" -> state.press(PadState.L3, down) "r3" -> state.press(PadState.R3, down) "guide" -> state.press(PadState.GUIDE, down) + "qam" -> state.press(PadState.QAM, down) "lt" -> state.leftTrigger = if (down) 1f else 0f "rt" -> state.rightTrigger = if (down) 1f else 0f "up" -> state.up = down 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 cfb0978cb..39202434d 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java +++ b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java @@ -52,6 +52,19 @@ public final class PadBridge { private final FakeInputWriter[] playerWriters = new FakeInputWriter[SLOTS]; private final PadState[] playerStates = new PadState[SLOTS]; private final PadState effectiveState = new PadState(); + private final PadState combinedState = new PadState(); + private InputRouter inputRouter; + private final InputRouter.PadSource[] physicalRoutes = new InputRouter.PadSource[SLOTS]; + private final java.util.IdentityHashMap touchSources = new java.util.IdentityHashMap<>(); + private final Object defaultTouchSource = new Object(); + + private static final class TouchSource { + final PadState raw = new PadState(); + final PadState passthrough = new PadState(); + final InputRole role; + InputRouter.PadSource route; + TouchSource(InputRole role) { this.role = role; } + } private final Handler mainHandler = new Handler(Looper.getMainLooper()); private boolean open; private volatile boolean selectSteam; @@ -64,6 +77,9 @@ public final class PadBridge { private int chordButton = -1; private boolean chordButtonPressed; private boolean qamTapPressed; + private boolean guideTapPressed; + private int guideTapGeneration; + private boolean routedQamPressed; private int qamChordGeneration; private volatile Runnable onPlayerInput; private final java.util.concurrent.atomic.AtomicBoolean playerInputPosted = new java.util.concurrent.atomic.AtomicBoolean(); @@ -107,6 +123,32 @@ public final class PadBridge { selectSteam = on; } + public synchronized void setInputRouter(InputRouter router) { + if (inputRouter == router) return; + for (InputRouter.PadSource route : physicalRoutes) if (route != null) route.close(); + for (TouchSource source : touchSources.values()) if (source.route != null) source.route.close(); + inputRouter = router; + for (int slot = 0; slot < SLOTS; slot++) physicalRoutes[slot] = router == null ? null : router.pad(InputRole.PHYSICAL); + for (TouchSource source : touchSources.values()) source.route = router == null ? null : router.pad(source.role); + } + + public synchronized boolean hasInputRouter() { return inputRouter != null; } + + public synchronized void setInputProfile(InputProfile profile) { + if (inputRouter == null) return; + inputRouter.setProfile(profile); + publish(); + for (int slot = 1; slot < SLOTS; slot++) if (playerWriters[slot] != null) publish(slot); + } + + public synchronized void setInputEnabled(boolean enabled) { + if (inputRouter == null) return; + inputRouter.setEnabled(enabled); + publish(); + for (int slot = 1; slot < SLOTS; slot++) if (playerWriters[slot] != null) publish(slot); + DeckControls.INSTANCE.refresh(); + } + public void setExcluded(java.util.function.IntPredicate devices) { excluded = devices; } @@ -129,9 +171,15 @@ public final class PadBridge { private void resetBuiltin() { routedTouch.clear(); + for (TouchSource source : touchSources.values()) { + source.raw.clear(); + if (source.route != null) source.route.release(); + } systemGuidePressed = false; systemQamPressed = false; qamTapPressed = false; + guideTapPressed = false; + guideTapGeneration++; qamChordActive = false; chordButtonPressed = false; qamChordGeneration++; @@ -149,6 +197,14 @@ public final class PadBridge { } public synchronized void stop() { + guideTapPressed = false; + guideTapGeneration++; + if (inputRouter != null) inputRouter.releaseAll(); + for (TouchSource source : touchSources.values()) if (source.route != null) source.route.close(); + touchSources.clear(); + for (InputRouter.PadSource route : physicalRoutes) if (route != null) route.close(); + Arrays.fill(physicalRoutes, null); + inputRouter = null; releaseBackPaddles(); activeControllerId = NO_CONTROLLER; for (int slot = 0; slot < SLOTS; slot++) { @@ -287,6 +343,7 @@ public final class PadBridge { if (slot < 0) return false; int side = JoyCon.side(event.getDevice()); if (side >= 0 && partners[slot] != NO_CONTROLLER && JoyCon.isRail(side, event.getScanCode())) return true; + long timing = InputTiming.begin(); PadState pad = stateFor(slot); boolean pressed = event.getAction() == KeyEvent.ACTION_DOWN; switch (event.getKeyCode()) { @@ -297,7 +354,7 @@ public final class PadBridge { case KeyEvent.KEYCODE_BUTTON_L1: pad.press(4, pressed); break; case KeyEvent.KEYCODE_BUTTON_R1: pad.press(5, pressed); break; case KeyEvent.KEYCODE_BUTTON_SELECT: - if (selectSteam) pad.press(PadState.GUIDE, pressed); + if (selectSteam && inputRouter == null) pad.press(PadState.GUIDE, pressed); else pad.press(6, pressed); break; case KeyEvent.KEYCODE_BACK: pad.press(6, pressed); break; @@ -326,6 +383,7 @@ public final class PadBridge { statButtons++; scheduleStats(); publish(slot); + InputTiming.end(timing); return true; } @@ -334,6 +392,7 @@ public final class PadBridge { if (event.getAction() != MotionEvent.ACTION_MOVE) return false; int slot = slotFor(event.getDevice()); if (slot < 0) return false; + long timing = InputTiming.begin(); PadState pad = stateFor(slot); noteDevice(event.getDevice(), slot); statAxes++; @@ -353,6 +412,7 @@ public final class PadBridge { } if (Math.max(Math.abs(x), Math.abs(y)) > 0.5f) notePlayerInput(); publish(slot); + InputTiming.end(timing); return true; } pad.leftX = axis(event, MotionEvent.AXIS_X); @@ -386,6 +446,7 @@ public final class PadBridge { notePlayerInput(); } publish(slot); + InputTiming.end(timing); return true; } @@ -407,32 +468,33 @@ public final class PadBridge { } public synchronized void applyTouch(java.util.function.Consumer mutation) { + applyTouch(defaultTouchSource, InputRole.PRIMARY, mutation); + } + + public synchronized void applyTouch(Object owner, InputRole role, java.util.function.Consumer mutation) { if (!builtinPad) return; - if (touchRoute != null && touchRoute.active()) { - mutation.accept(routedTouch); - touchRoute.accept(routedTouch); - boolean changed = false; - for (int button : ROUTED_PASS_THROUGH) { - boolean down = routedTouch.isDown(button); - if (state.isDown(button) == down) continue; - state.press(button, down); - changed = true; - } - if (changed) { - activeControllerId = NO_CONTROLLER; - slotControllers.set(0, NO_CONTROLLER); - publish(); - } - return; + long timing = InputTiming.begin(); + TouchSource source = touchSources.get(owner); + if (source == null) { + source = new TouchSource(role); + if (inputRouter != null) source.route = inputRouter.pad(role); + touchSources.put(owner, source); } - routedTouch.clear(); - mutation.accept(state); + mutation.accept(source.raw); activeControllerId = NO_CONTROLLER; slotControllers.set(0, NO_CONTROLLER); notePlayerInput(); statOnScreen++; scheduleStats(); publish(); + InputTiming.end(timing); + } + + public synchronized void releaseTouch(Object owner) { + TouchSource source = touchSources.remove(owner); + if (source == null) return; + if (source.route != null) source.route.close(); + publish(); } public synchronized void applyExternal(java.util.function.Consumer mutation, boolean changed) { @@ -457,13 +519,24 @@ public final class PadBridge { } public synchronized void releaseAll() { + guideTapGeneration++; + routedQamPressed = false; + systemGuidePressed = false; + systemQamPressed = false; + guideTapPressed = false; + qamTapPressed = false; + qamChordActive = false; + chordButtonPressed = false; + qamChordGeneration++; + if (inputRouter != null) inputRouter.releaseAll(); + if (inputRouter == null) for (TouchSource source : touchSources.values()) source.raw.clear(); routedTouch.clear(); - releaseBackPaddles(); - state.clear(); + DeckControls.INSTANCE.suspend(); + if (inputRouter == null) state.clear(); publish(); for (int slot = 1; slot < SLOTS; slot++) { if (playerWriters[slot] == null) continue; - playerStates[slot].clear(); + if (inputRouter == null) playerStates[slot].clear(); publish(slot); } } @@ -491,6 +564,20 @@ public final class PadBridge { chordHandler.postDelayed(() -> releaseQamTap(generation), QAM_A_HOLD_MS); } + public synchronized void triggerSteamMenu() { + if (!builtinPad || guideTapPressed) return; + guideTapPressed = true; + int generation = ++guideTapGeneration; + publish(); + chordHandler.postDelayed(() -> { + synchronized (PadBridge.this) { + if (generation != guideTapGeneration) return; + guideTapPressed = false; + publish(); + } + }, 90); + } + private synchronized void releaseQamTap(int generation) { if (generation != qamChordGeneration || !qamTapPressed) return; qamTapPressed = false; @@ -606,6 +693,7 @@ public final class PadBridge { } private void free(int slot) { + if (physicalRoutes[slot] != null) physicalRoutes[slot].release(); Log.i(TAG, "player " + (slot + 1) + " (id " + owners[slot] + ") left"); owners[slot] = NO_CONTROLLER; partners[slot] = NO_CONTROLLER; @@ -658,6 +746,7 @@ public final class PadBridge { publish(); return; } + PadState output = physicalRoutes[slot] == null ? playerStates[slot] : physicalRoutes[slot].route(playerStates[slot]); FakeInputWriter player = playerWriters[slot]; if (player == null) { player = new FakeInputWriter(fakeInputDir.getAbsolutePath(), slot); @@ -667,22 +756,47 @@ public final class PadBridge { } playerWriters[slot] = player; } - player.writePad(playerStates[slot]); + player.writePad(output); } private void publish() { if (!builtinPad && !open && owners[0] == NO_CONTROLLER && partners[0] == NO_CONTROLLER) return; + PadState output = physicalRoutes[0] == null ? state : physicalRoutes[0].route(state); + if (!touchSources.isEmpty()) { + combinedState.copyFrom(output); + routedTouch.clear(); + boolean epic = touchRoute != null && touchRoute.active(); + for (TouchSource source : touchSources.values()) { + PadState touch = source.route == null ? source.raw : source.route.route(source.raw); + if (epic) { + routedTouch.merge(touch); + source.passthrough.clear(); + for (int button : ROUTED_PASS_THROUGH) source.passthrough.press(button, touch.isDown(button)); + combinedState.merge(source.passthrough); + } else combinedState.merge(touch); + } + if (epic) touchRoute.accept(routedTouch); + output = combinedState; + } + boolean guide = systemGuidePressed || qamChordActive || guideTapPressed; + boolean routedQam = output.isDown(PadState.QAM); + boolean routedQamStarted = routedQam && !routedQamPressed; + routedQamPressed = routedQam; + if (routedQamStarted && !nativeQam()) { + startChord(PadState.A); + return; + } + boolean qam = nativeQam() && (systemQamPressed || qamTapPressed); if (!open && !start()) { statDropped++; return; } - boolean guide = systemGuidePressed || qamChordActive; - boolean qam = nativeQam() && (systemQamPressed || qamTapPressed); - if (!guide && !qam) { - writer.writePad(state); + if (!guide && !qam && (!routedQam || nativeQam())) { + writer.writePad(output); return; } - effectiveState.copyFrom(state); + effectiveState.copyFrom(output); + if (!nativeQam()) effectiveState.press(PadState.QAM, false); if (guide) effectiveState.press(PadState.GUIDE, true); if (qam) effectiveState.press(PadState.QAM, true); if (chordButtonPressed) effectiveState.press(chordButton, true); diff --git a/app/src/main/java/com/droiddeck/launcher/input/PadState.kt b/app/src/main/java/com/droiddeck/launcher/input/PadState.kt index d337a2a32..a1261dcd8 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadState.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/PadState.kt @@ -36,6 +36,19 @@ class PadState { buttons = 0 } + fun merge(other: PadState) { + buttons = buttons or other.buttons + up = up || other.up; right = right || other.right; down = down || other.down; left = left || other.left + leftTrigger = maxOf(leftTrigger, other.leftTrigger) + rightTrigger = maxOf(rightTrigger, other.rightTrigger) + if (other.leftX * other.leftX + other.leftY * other.leftY > leftX * leftX + leftY * leftY) { + leftX = other.leftX; leftY = other.leftY + } + if (other.rightX * other.rightX + other.rightY * other.rightY > rightX * rightX + rightY * rightY) { + rightX = other.rightX; rightY = other.rightY + } + } + companion object { const val A = 0 const val B = 1 diff --git a/app/src/main/java/com/droiddeck/launcher/input/PcKeys.kt b/app/src/main/java/com/droiddeck/launcher/input/PcKeys.kt new file mode 100644 index 000000000..f3ab1d4c6 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/PcKeys.kt @@ -0,0 +1,60 @@ +package com.droiddeck.launcher.input + +import android.content.Context +import androidx.annotation.StringRes +import com.droiddeck.launcher.R + +data class PcKey( + val label: String, + val code: Int, + val weight: Float = 1f, + val shifted: String? = null, + @StringRes val labelRes: Int = 0, +) + +internal fun k(label: String, code: Int, weight: Float = 1f, shifted: String? = null) = PcKey(label, code, weight, shifted) + +internal fun k(@StringRes labelRes: Int, code: Int, weight: Float = 1f) = PcKey("", code, weight, null, labelRes) + +val PC_KEY_ROWS: List> = listOf( + listOf( + k(R.string.pckb_key_esc, 1), k("F1", 59), k("F2", 60), k("F3", 61), k("F4", 62), k("F5", 63), k("F6", 64), + k("F7", 65), k("F8", 66), k("F9", 67), k("F10", 68), k("F11", 87), k("F12", 88), + k(R.string.pckb_key_ins, 110), k(R.string.pckb_key_del, 111), k(R.string.pckb_key_home, 102), k(R.string.pckb_key_end, 107), k(R.string.pckb_key_pgup, 104), k(R.string.pckb_key_pgdn, 109), + ), + listOf( + k("`", 41, shifted = "~"), k("1", 2, shifted = "!"), k("2", 3, shifted = "@"), k("3", 4, shifted = "#"), + k("4", 5, shifted = "$"), k("5", 6, shifted = "%"), k("6", 7, shifted = "^"), k("7", 8, shifted = "&"), + k("8", 9, shifted = "*"), k("9", 10, shifted = "("), k("0", 11, shifted = ")"), k("-", 12, shifted = "_"), + k("=", 13, shifted = "+"), k(R.string.pckb_key_backspace, 14, 2f), + ), + listOf( + k(R.string.pckb_key_tab, 15, 1.5f), k("q", 16), k("w", 17), k("e", 18), k("r", 19), k("t", 20), k("y", 21), + k("u", 22), k("i", 23), k("o", 24), k("p", 25), k("[", 26, shifted = "{"), k("]", 27, shifted = "}"), + k("\\", 43, 1.5f, shifted = "|"), + ), + listOf( + k(R.string.pckb_key_caps, 58, 1.75f), k("a", 30), k("s", 31), k("d", 32), k("f", 33), k("g", 34), k("h", 35), + k("j", 36), k("k", 37), k("l", 38), k(";", 39, shifted = ":"), k("'", 40, shifted = "\""), + k(R.string.pckb_key_enter, 28, 2.25f), + ), + listOf( + k(R.string.pckb_key_shift, 42, 2.25f), k("z", 44), k("x", 45), k("c", 46), k("v", 47), k("b", 48), k("n", 49), + k("m", 50), k(",", 51, shifted = "<"), k(".", 52, shifted = ">"), k("/", 53, shifted = "?"), + k(R.string.pckb_key_shift, 54, 1.75f), k("↑", 103), + ), + listOf( + k(R.string.pckb_key_ctrl, 29, 1.5f), k(R.string.pckb_key_super, 125, 1.25f), k(R.string.pckb_key_alt, 56, 1.25f), k(R.string.pckb_key_space, 57, 6.25f), + k(R.string.pckb_key_altgr, 100, 1.25f), k(R.string.pckb_key_ctrl, 97, 1.25f), k("←", 105), k("↓", 108), k("→", 106), + ), +) + +val PC_MODIFIERS: Set = setOf(42, 54, 29, 97, 56, 100, 125) + +val PC_KEYS: Map = PC_KEY_ROWS.flatten().associateBy { it.code } + +fun pcKeyLabel(context: Context, code: Int): String { + val key = PC_KEYS[code] ?: return code.toString() + val text = if (key.labelRes != 0) context.getString(key.labelRes) else key.label + return if (text.length == 1 && text[0].isLetter()) text.uppercase() else text +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/PhysicalInputBindings.kt b/app/src/main/java/com/droiddeck/launcher/input/PhysicalInputBindings.kt new file mode 100644 index 000000000..fcb883a99 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/PhysicalInputBindings.kt @@ -0,0 +1,89 @@ +package com.droiddeck.launcher.input + +import android.view.InputDevice +import android.view.KeyEvent + +data class PhysicalInputKey(val device: String, val name: String, val keyCode: Int, val scanCode: Int) { + val label: String get() = "$name · ${KeyEvent.keyCodeToString(keyCode).removePrefix("KEYCODE_")} ($scanCode)" + + companion object { + fun identity(device: InputDevice) = "${device.vendorId}:${device.productId}:${device.descriptor.ifBlank { device.name }}" + + fun capture(event: KeyEvent): PhysicalInputKey? { + val device = event.device ?: return null + if (device.isVirtual || PadBridge.isSystemVirtualInputName(device.name)) return null + when (event.keyCode) { + KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_MUTE, + KeyEvent.KEYCODE_HOME, KeyEvent.KEYCODE_POWER, KeyEvent.KEYCODE_SLEEP, + KeyEvent.KEYCODE_WAKEUP, KeyEvent.KEYCODE_APP_SWITCH -> return null + } + if (event.keyCode == KeyEvent.KEYCODE_UNKNOWN && event.scanCode <= 0) return null + return PhysicalInputKey(identity(device), device.name, event.keyCode, event.scanCode) + } + } +} + +data class PhysicalInputBinding(val key: PhysicalInputKey, val action: InputAction) + +class PhysicalInputBindings(private val router: InputRouter) { + private class Entry(val binding: PhysicalInputBinding, val channel: InputRouter.Channel) { + var heldDevice = -1 + var suppressed = false + } + private var entries = emptyList() + private val released = ArrayList() + private val devices = HashMap() + + fun configure(bindings: List) { + if (entries.map { it.binding } == bindings) return + for (entry in entries) { + entry.channel.close() + if (entry.heldDevice != -1) released.add(entry) + } + entries = bindings.map { Entry(it, router.channel(InputRole.PHYSICAL, InputControl.A, it.action)) } + } + + fun onKey(event: KeyEvent, enabled: Boolean): Boolean { + if (event.action != KeyEvent.ACTION_DOWN && event.action != KeyEvent.ACTION_UP) return false + for (i in released.indices.reversed()) { + val old = released[i] + if (old.heldDevice == event.deviceId && old.binding.key.keyCode == event.keyCode && old.binding.key.scanCode == event.scanCode) { + if (event.action == KeyEvent.ACTION_UP) released.removeAt(i) + return true + } + } + for (entry in entries) { + val key = entry.binding.key + if (key.keyCode != event.keyCode || key.scanCode != event.scanCode) continue + if (entry.heldDevice != -1 && entry.heldDevice == event.deviceId) { + if (event.action == KeyEvent.ACTION_UP) { + entry.channel.update(0f); entry.heldDevice = -1; entry.suppressed = false + } + return true + } + if (!enabled || event.action != KeyEvent.ACTION_DOWN || entry.binding.action is InputAction.Native) continue + val identity = devices[event.deviceId] ?: event.device?.let { PhysicalInputKey.identity(it) }?.also { devices[event.deviceId] = it } ?: continue + if (identity != key.device) continue + entry.heldDevice = event.deviceId + entry.suppressed = event.repeatCount != 0 + if (!entry.suppressed) entry.channel.update(1f) + return true + } + return false + } + + fun release() { + for (entry in entries) { + entry.channel.release() + if (entry.heldDevice != -1) entry.suppressed = true + } + } + + fun disconnected(id: Int) { + devices.remove(id) + released.removeAll { it.heldDevice == id } + for (entry in entries) if (entry.heldDevice == id) { entry.channel.release(); entry.heldDevice = -1 } + } + + fun close() { entries.forEach { it.channel.close() }; entries = emptyList(); released.clear(); devices.clear() } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt b/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt index 6789654f7..5e9ea8ad8 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt @@ -10,6 +10,7 @@ enum class SecondScreenMode(val id: String, @StringRes val label: Int) { KEYBOARD_TRACKPAD("keyboard-trackpad", R.string.second_mode_keyboard_trackpad), TERMINAL("terminal", R.string.second_mode_terminal), DECK_CONTROLS("deck-controls", R.string.second_mode_deck_controls), + CUSTOM_CONTROLS("custom-controls", R.string.input_custom_controls), } data class SecondScreenDisplay(val id: Int, val label: String) diff --git a/app/src/main/java/com/droiddeck/launcher/input/SteamControllerBle.kt b/app/src/main/java/com/droiddeck/launcher/input/SteamControllerBle.kt index e77b322f2..a7c57a39c 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/SteamControllerBle.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/SteamControllerBle.kt @@ -20,7 +20,8 @@ import java.util.ArrayDeque import java.util.UUID @SuppressLint("MissingPermission") -class SteamControllerBle(private val context: Context) { +class SteamControllerBle(private val context: Context, router: InputRouter? = null) { + private val routedInput = router?.let(::TritonInputRouter) private val thread = HandlerThread("steam-controller-ble").apply { start() } private val handler = Handler(thread.looper) private val outputReports = mutableMapOf() @@ -47,6 +48,7 @@ class SteamControllerBle(private val context: Context) { running = false handler.removeCallbacksAndMessages(null) disconnect("session ended") + routedInput?.close() thread.quitSafely() } @@ -217,10 +219,13 @@ class SteamControllerBle(private val context: Context) { private fun onState(data: ByteArray) { if (data.size < STATE_BYTES) return - FakeInputWriter.writeTritonState(SLOT, reportId, data) + val timing = InputTiming.begin() + FakeInputWriter.writeTritonState(SLOT, reportId, routedInput?.route(data) ?: data) + InputTiming.end(timing) } private fun release() { + routedInput?.release() FakeInputWriter.writeTritonState(SLOT, if (reportId == 0x47) 0x47 else 0x45, ByteArray(STATE_BYTES)) } diff --git a/app/src/main/java/com/droiddeck/launcher/input/TritonInputRouter.kt b/app/src/main/java/com/droiddeck/launcher/input/TritonInputRouter.kt new file mode 100644 index 000000000..c85b4ffa9 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/TritonInputRouter.kt @@ -0,0 +1,93 @@ +package com.droiddeck.launcher.input + +import kotlin.math.roundToInt + +class TritonInputRouter(private val router: InputRouter) { + private val channels = InputControl.entries.map { router.channel(InputRole.PHYSICAL, it) }.toTypedArray() + private var last: ByteArray? = null + private var buffer = ByteArray(45) + private val snapshot: () -> Unit = { last?.let { read(it, false) } } + + init { router.beforeProfileChange(snapshot) } + + fun route(data: ByteArray): ByteArray = synchronized(router) { + if (data.size < 45) return@synchronized data + last = data + if (channels.all { it.nativeIdentity() }) return@synchronized data + if (buffer.size != data.size) buffer = ByteArray(data.size) + data.copyInto(buffer) + putBits(buffer, bits(data) and CONTROL_BITS.inv()) + for (offset in 5..27 step 2) putShort(buffer, offset, 0) + read(data, true) + buffer + } + + private fun read(data: ByteArray, emit: Boolean) { + val bits = bits(data) + for (control in InputControl.entries) { + val channel = channels[control.ordinal] + val offset = offset(control) + val pair = control.dimensions == 2 + val x = if (offset != 0) axis(data, offset) else if (bits and bit(control) != 0) 1f else 0f + val y = if (pair) axis(data, offset + 2) * if (control.isTrackpad) 1f else -1f else 0f + val contact = control.isTrackpad && bits and bit(control) != 0 + if (!emit) { channel.observe(x, y, contact); continue } + val target = channel.update(x, y, contact) ?: continue + val targetOffset = offset(target) + if (targetOffset != 0) { + val value = shortValue(x) + if (target.dimensions == 2) { + val second = shortValue(y * if (target.isTrackpad) 1f else -1f) + val oldX = short(buffer, targetOffset).toLong() + val oldY = short(buffer, targetOffset + 2).toLong() + if (value.toLong() * value + second.toLong() * second >= oldX * oldX + oldY * oldY) { + putShort(buffer, targetOffset, value) + putShort(buffer, targetOffset + 2, second) + } + val touch = if (control.isTrackpad) contact else bits and bit(control) != 0 + if (touch) putBits(buffer, bits(buffer) or bit(target)) + } else { + if (value >= short(buffer, targetOffset)) putShort(buffer, targetOffset, value) + if (bits and bit(control) != 0) putBits(buffer, bits(buffer) or bit(target)) + } + } else if (x > 0.5f) putBits(buffer, bits(buffer) or bit(target)) + if (target == InputControl.LEFT_CLICK || target == InputControl.RIGHT_CLICK) { + val pressureOffset = if (target == InputControl.LEFT_CLICK) 21 else 27 + val from = if (control == InputControl.LEFT_CLICK) 21 else if (control == InputControl.RIGHT_CLICK) 27 else 0 + if (from != 0) putShort(buffer, pressureOffset, short(data, from)) + else if (x > 0.5f) putShort(buffer, pressureOffset, 32767) + } + } + } + + fun release() = synchronized(router) { channels.forEach { it.release() }; last = null } + fun close() = synchronized(router) { release(); channels.forEach { it.close() }; router.removeSnapshot(snapshot) } + + private fun bits(data: ByteArray): Int = (data[1].toInt() and 255) or ((data[2].toInt() and 255) shl 8) or + ((data[3].toInt() and 255) shl 16) or ((data[4].toInt() and 255) shl 24) + private fun putBits(data: ByteArray, value: Int) { for (i in 0..3) data[i + 1] = (value ushr (i * 8)).toByte() } + private fun short(data: ByteArray, offset: Int) = ((data[offset].toInt() and 255) or (data[offset + 1].toInt() shl 8)).toShort().toInt() + private fun axis(data: ByteArray, offset: Int): Float = short(data, offset).let { it / if (it < 0) 32768f else 32767f } + private fun shortValue(value: Float) = (value * if (value < 0f) 32768f else 32767f).roundToInt().coerceIn(-32768, 32767) + private fun putShort(data: ByteArray, offset: Int, value: Int) { data[offset] = value.toByte(); data[offset + 1] = (value shr 8).toByte() } + + companion object { + private const val CONTROL_BITS = 0x0fffffff + private fun bit(c: InputControl): Int = when (c) { + InputControl.A -> 0x1; InputControl.B -> 0x2; InputControl.X -> 0x4; InputControl.Y -> 0x8 + InputControl.QAM -> 0x10; InputControl.R3 -> 0x20; InputControl.START -> 0x40 + InputControl.R4 -> 0x80; InputControl.R5 -> 0x100; InputControl.RB -> 0x200 + InputControl.DOWN -> 0x400; InputControl.RIGHT -> 0x800; InputControl.LEFT -> 0x1000; InputControl.UP -> 0x2000 + InputControl.SELECT -> 0x4000; InputControl.L3 -> 0x8000; InputControl.GUIDE -> 0x10000 + InputControl.L4 -> 0x20000; InputControl.L5 -> 0x40000; InputControl.LB -> 0x80000 + InputControl.RS -> 0x100000; InputControl.RIGHT_PAD -> 0x200000; InputControl.RIGHT_CLICK -> 0x400000 + InputControl.RT -> 0x800000; InputControl.LS -> 0x1000000; InputControl.LEFT_PAD -> 0x2000000 + InputControl.LEFT_CLICK -> 0x4000000; InputControl.LT -> 0x8000000 + } + private fun offset(c: InputControl): Int = when (c) { + InputControl.LT -> 5; InputControl.RT -> 7; InputControl.LS -> 9; InputControl.RS -> 13 + InputControl.LEFT_PAD -> 17; InputControl.RIGHT_PAD -> 23 + else -> 0 + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt b/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt index ff39fb00f..772b9593c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt @@ -28,7 +28,6 @@ class ControllerActions( val onSteamButton: (Boolean) -> Unit, val onQamButton: (Boolean) -> Unit, val onKeyboardButton: (Boolean) -> Unit, - val onSelectSteam: (Boolean) -> Unit, val onJoyConVr: (Boolean) -> Unit, val onEditLayout: () -> Unit, val onResetLayout: () -> Unit, @@ -72,7 +71,6 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre ToggleRow(host, "controller-steam", stringResource(R.string.ctrl_steam_button), null, c.steamButton, onChange = a.onSteamButton) ToggleRow(host, "controller-qam", stringResource(R.string.ctrl_qam_button), null, c.qamButton, onChange = a.onQamButton) ToggleRow(host, "controller-keyboard", stringResource(R.string.ctrl_keyboard_button), null, c.keyboardButton, onChange = a.onKeyboardButton) - ToggleRow(host, "controller-select-steam", stringResource(R.string.ctrl_select_steam), stringResource(R.string.ctrl_select_steam_hint), c.selectSteam, onChange = a.onSelectSteam) ToggleRow(host, "controller-joycon-vr", stringResource(R.string.ctrl_joycon_vr), stringResource(R.string.ctrl_joycon_vr_hint), c.joyConVr, onChange = a.onJoyConVr) SettingsRow(stringResource(R.string.ctrl_layout), if (c.customLayout) stringResource(R.string.ctrl_layout_custom) else stringResource(R.string.ctrl_layout_default)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { @@ -80,27 +78,6 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre SecondaryButton(stringResource(R.string.ctrl_reset), enabled = c.customLayout) { a.onResetLayout() } } } - val remapped = c.mapping.count { (id, target) -> id != target } - ActionRow(stringResource(R.string.ctrl_mapping), if (remapped == 0) stringResource(R.string.ctrl_mapping_default) else stringResource(R.string.ctrl_mapping_count, remapped, c.mapping.size), stringResource(R.string.ctrl_configure), a.onMapping) + ActionRow(stringResource(R.string.input_settings), stringResource(R.string.input_settings_hint), stringResource(R.string.ctrl_configure), a.onMapping) ActionRow(stringResource(R.string.ctrl_reset_all), stringResource(R.string.ctrl_reset_all_hint), stringResource(R.string.ctrl_reset), a.onResetAll) } - -@Composable -fun ControllerMappingPage(mapping: Map, onPick: (String, String) -> Unit, onReset: () -> Unit, onBack: () -> Unit) { - val host = rememberMenuHost() - SettingsPage( - host, title = stringResource(R.string.ctrl_mapping), eyebrow = stringResource(R.string.setup_controller), - lede = stringResource(R.string.ctrl_mapping_lede), - onBack = onBack, - ) { - SettingsGroup(stringResource(R.string.ctrl_onscreen_buttons)) { - val targets = ControllerPrefs.targets.map { (id, label) -> id to stringResource(label) } - for ((id, name) in ControllerPrefs.mappable) { - ChoiceRow(host, "map-$id", stringResource(name), null, targets, mapping[id] ?: id) { onPick(id, it) } - } - } - SettingsGroup(stringResource(R.string.ctrl_defaults)) { - ActionRow(stringResource(R.string.ctrl_reset_mapping), stringResource(R.string.ctrl_mapping_default), stringResource(R.string.ctrl_reset), onReset) - } - } -} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt index 0a603d5a5..2cc80047a 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt @@ -21,6 +21,9 @@ import android.widget.HorizontalScrollView import android.widget.LinearLayout import android.widget.TextView import com.droiddeck.launcher.input.DeckControls +import com.droiddeck.launcher.input.InputRole +import com.droiddeck.launcher.input.InputControl +import com.droiddeck.launcher.input.InputSurfaceElement import com.droiddeck.launcher.input.PadBridge import com.droiddeck.launcher.input.PadState import com.droiddeck.launcher.session.SessionService @@ -37,9 +40,12 @@ class DeckControlsPanel( private val onSteamMenu: () -> Unit, private val onQam: () -> Unit, private val pad: PadBridge? = null, + private val customLayout: List? = null, + private val inputRole: InputRole = InputRole.SECONDARY, ) : LinearLayout(context) { private enum class Tab(val label: Int) { GAMEPAD(R.string.deck_tab_gamepad), GRIPS(R.string.deck_tab_grips), LEFT(R.string.deck_tab_left), RIGHT(R.string.deck_tab_right), BOTH(R.string.deck_tab_both) } + private val deckSource = DeckControls.source(inputRole) private val content = FrameLayout(context) private val tabViews = HashMap() private val topBar: View by lazy { topBar() } @@ -49,11 +55,59 @@ class DeckControlsPanel( setBackgroundColor(Color.BLACK) addView(topBar, LayoutParams(LayoutParams.MATCH_PARENT, dp(64))) addView(content, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f)) - select(if (pad != null) selectedWithPad else selected) + if (customLayout != null && pad != null) renderCustom(pad, customLayout) + else select(if (pad != null) selectedWithPad else selected) + } + + private fun renderCustom(pad: PadBridge, layout: List) { + topBar.visibility = View.GONE + val canvas = object : ViewGroup(context) { + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val w = MeasureSpec.getSize(widthMeasureSpec) + val h = MeasureSpec.getSize(heightMeasureSpec) + setMeasuredDimension(w, h) + for (i in layout.indices) getChildAt(i).measure(MeasureSpec.makeMeasureSpec((w * layout[i].width).toInt(), MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec((h * layout[i].height).toInt(), MeasureSpec.EXACTLY)) + } + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + for (i in layout.indices) { + val e = layout[i] + val child = getChildAt(i) + val x = (width * e.x).toInt(); val y = (height * e.y).toInt() + child.layout(x, y, x + child.measuredWidth, y + child.measuredHeight) + } + } + } + for (element in layout) { + val c = element.control + canvas.addView(when (c) { + InputControl.LS, InputControl.RS -> StickView(context, pad, c == InputControl.RS) + InputControl.LEFT_PAD, InputControl.RIGHT_PAD -> TrackpadView(context, c == InputControl.RIGHT_PAD) + InputControl.LEFT_CLICK, InputControl.RIGHT_CLICK -> ClickBar(context, c == InputControl.RIGHT_CLICK) + InputControl.L4, InputControl.R4, InputControl.L5, InputControl.R5 -> GripButton(context, c.label, null, 1 shl (c.ordinal - InputControl.L4.ordinal)) + else -> PadButton(context, pad, c.label) { state, down -> + when (c) { + InputControl.LT -> state.leftTrigger = if (down) 1f else 0f + InputControl.RT -> state.rightTrigger = if (down) 1f else 0f + InputControl.UP -> state.up = down + InputControl.RIGHT -> state.right = down + InputControl.DOWN -> state.down = down + InputControl.LEFT -> state.left = down + else -> state.press(when (c) { + InputControl.GUIDE -> PadState.GUIDE; InputControl.QAM -> PadState.QAM + else -> c.ordinal + }, down) + } + } + }) + } + content.addView(canvas, FrameLayout.LayoutParams(-1, -1)) } override fun onDetachedFromWindow() { - DeckControls.releaseAll() + deckSource.release() + pad?.releaseTouch(this) + deckSource.close() super.onDetachedFromWindow() } @@ -96,7 +150,8 @@ class DeckControlsPanel( private fun select(tab: Tab) { if (pad != null) selectedWithPad = tab else selected = tab - DeckControls.releaseAll() + deckSource.release() + pad?.releaseTouch(this) for ((each, view) in tabViews) { val on = each == tab view.background = if (on) filled(CORNER_PILL, ACCENT) else null @@ -332,13 +387,13 @@ class DeckControlsPanel( } } - private class GripButton(context: Context, label: String, caption: String?, bit: Int) : - HoldButton(context, label, caption, { DeckControls.setGrip(bit, it) }) + private inner class GripButton(context: Context, label: String, caption: String?, bit: Int) : + HoldButton(context, label, caption, { deckSource.setGrip(bit, it) }) - private class ClickBar(context: Context, right: Boolean) : - HoldButton(context, context.getString(R.string.deck_click), if (right) context.getString(R.string.deck_right_trackpad) else context.getString(R.string.deck_left_trackpad), { DeckControls.setClick(right, it) }) + private inner class ClickBar(context: Context, right: Boolean) : + HoldButton(context, context.getString(R.string.deck_click), if (right) context.getString(R.string.deck_right_trackpad) else context.getString(R.string.deck_left_trackpad), { deckSource.setClick(right, it) }) - private class TrackpadView(context: Context, private val right: Boolean, private val captioned: Boolean = true) : Surface(context) { + private inner class TrackpadView(context: Context, private val right: Boolean, private val captioned: Boolean = true) : Surface(context) { private var touching = false private var clicked = false private var fingerX = 0f @@ -384,7 +439,7 @@ class DeckControlsPanel( MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { click(false) touching = false - DeckControls.setPad(right, touching = false) + deckSource.setPad(right, touching = false) invalidate() } } @@ -395,7 +450,7 @@ class DeckControlsPanel( fingerX = x.coerceIn(0f, width.toFloat()) fingerY = y.coerceIn(0f, height.toFloat()) touching = true - DeckControls.setPad(right, true, (fingerX - width / 2f) / (width / 2f), -(fingerY - height / 2f) / (height / 2f)) + deckSource.setPad(right, true, (fingerX - width / 2f) / (width / 2f), -(fingerY - height / 2f) / (height / 2f)) invalidate() } @@ -403,24 +458,25 @@ class DeckControlsPanel( if (clicked == down) return clicked = down if (down) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) - DeckControls.setClick(right, down) + deckSource.setClick(right, down) invalidate() } } - private class PadButton( + private inner class PadButton( context: Context, private val pad: PadBridge, label: String, private val write: (PadState, Boolean) -> Unit, - ) : HoldButton(context, label, null, { down -> pad.applyTouch { write(it, down) } }) { + ) : HoldButton(context, label, null, { down -> pad.applyTouch(this@DeckControlsPanel, inputRole) { write(it, down) } }) { override fun onDetachedFromWindow() { - pad.applyTouch { write(it, false) } - super.onDetachedFromWindow() + pad.applyTouch(this@DeckControlsPanel, inputRole) { write(it, false) } + deckSource.close() + super.onDetachedFromWindow() } } - private class StickView(context: Context, private val pad: PadBridge, private val right: Boolean) : Surface(context) { + private inner class StickView(context: Context, private val pad: PadBridge, private val right: Boolean) : Surface(context) { private var touching = false private var clicking = false private var lastUp = 0L @@ -478,7 +534,7 @@ class DeckControlsPanel( private fun move(dx: Float, dy: Float, r: Float) { knobX = dx knobY = dy - pad.applyTouch { s -> + pad.applyTouch(this@DeckControlsPanel, inputRole) { s -> if (right) { s.rightX = dx / r; s.rightY = dy / r } else { s.leftX = dx / r; s.leftY = dy / r } } invalidate() @@ -488,17 +544,18 @@ class DeckControlsPanel( if (clicking == down) return clicking = down if (down) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) - pad.applyTouch { s -> s.press(if (right) PadState.R3 else PadState.L3, down) } + pad.applyTouch(this@DeckControlsPanel, inputRole) { s -> s.press(if (right) PadState.R3 else PadState.L3, down) } } override fun onDetachedFromWindow() { setClick(false) if (knobX != 0f || knobY != 0f) move(0f, 0f, 1f) - super.onDetachedFromWindow() + deckSource.close() + super.onDetachedFromWindow() } } - private class DirectionPad(context: Context, private val pad: PadBridge) : View(context) { + private inner class DirectionPad(context: Context, private val pad: PadBridge) : View(context) { private val density = resources.displayMetrics.density private var held = 0 private val arm = Paint(Paint.ANTI_ALIAS_FLAG) @@ -544,7 +601,7 @@ class DeckControlsPanel( if (bits == held) return if (bits and held.inv() != 0) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) held = bits - pad.applyTouch { s -> + pad.applyTouch(this@DeckControlsPanel, inputRole) { s -> s.up = bits and 1 != 0; s.right = bits and 2 != 0; s.down = bits and 4 != 0; s.left = bits and 8 != 0 } invalidate() @@ -552,11 +609,12 @@ class DeckControlsPanel( override fun onDetachedFromWindow() { set(0) - super.onDetachedFromWindow() + deckSource.close() + super.onDetachedFromWindow() } } - private class FaceButtons(context: Context, private val pad: PadBridge) : View(context) { + private inner class FaceButtons(context: Context, private val pad: PadBridge) : View(context) { private val buttons = listOf(PadState.Y to "Y", PadState.B to "B", PadState.A to "A", PadState.X to "X") private var held = emptySet() private val fill = Paint(Paint.ANTI_ALIAS_FLAG) @@ -607,13 +665,14 @@ class DeckControlsPanel( if ((now - held).isNotEmpty()) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) val before = held held = now - pad.applyTouch { s -> for (b in before + now) s.press(b, b in now) } + pad.applyTouch(this@DeckControlsPanel, inputRole) { s -> for (b in before + now) s.press(b, b in now) } invalidate() } override fun onDetachedFromWindow() { set(emptySet()) - super.onDetachedFromWindow() + deckSource.close() + super.onDetachedFromWindow() } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/InputSettingsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/InputSettingsPage.kt new file mode 100644 index 000000000..b96ea3e97 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/InputSettingsPage.kt @@ -0,0 +1,222 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.focusable +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.droiddeck.launcher.R +import com.droiddeck.launcher.input.* + +@Composable +fun InputSettingsDialog(onBack: () -> Unit, onChanged: () -> Unit) { + Dialog(onDismissRequest = onBack, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Surface(Modifier.fillMaxWidth(0.95f).fillMaxHeight(0.95f)) { + InputSettingsPage(onBack, onChanged) + } + } +} + +@Composable +fun InputSettingsPage(onBack: () -> Unit, onChanged: () -> Unit = {}) { + val context = LocalContext.current + val host = rememberMenuHost() + var measuring by remember { mutableStateOf(InputTiming.enabled) } + var timing by remember { mutableStateOf(InputTiming.report()) } + var id by remember { mutableStateOf(InputProfile.GAME) } + var profile by remember { mutableStateOf(InputProfiles.read(context, id)) } + var selected by remember { mutableStateOf(InputProfiles.selection(context)) } + var role by remember { mutableStateOf(InputRole.PHYSICAL) } + var edit by remember { mutableStateOf(null) } + var capture by remember { mutableStateOf(false) } + var editKey by remember { mutableStateOf(null) } + var surface by remember { mutableStateOf(false) } + val names = listOf(InputProfile.GAME to stringResource(R.string.input_profile_game), InputProfile.DESKTOP to stringResource(R.string.input_profile_desktop)) + SettingsPage(host, title = stringResource(R.string.input_settings), eyebrow = stringResource(R.string.setup_controller), + lede = stringResource(R.string.input_settings_hint), onBack = onBack) { + SettingsGroup(stringResource(R.string.input_profiles)) { + ChoiceRow(host, "input-active", stringResource(R.string.input_active_profile), stringResource(R.string.input_auto_hint), + listOf(InputProfiles.AUTO to stringResource(R.string.common_auto)) + names, selected) { + InputProfiles.select(context, it); selected = it; onChanged() + } + ChoiceRow(host, "input-edit-profile", stringResource(R.string.input_edit_profile), null, names, id) { + id = it; profile = InputProfiles.read(context, it) + } + ChoiceRow(host, "input-source", stringResource(R.string.input_source), null, + listOf(InputRole.PHYSICAL.name to stringResource(R.string.input_physical), InputRole.PRIMARY.name to stringResource(R.string.input_primary), + InputRole.SECONDARY.name to stringResource(R.string.input_secondary)), role.name) { role = InputRole.valueOf(it) } + } + SettingsGroup(stringResource(R.string.input_bindings)) { + for (control in InputControl.entries) { + ActionRow(control.label, actionLabel(profile.action(role, control)), stringResource(R.string.ctrl_edit), { edit = control }, modifier = Modifier.testTag("input-binding-${control.id}")) + } + } + if (role == InputRole.PHYSICAL) SettingsGroup(stringResource(R.string.input_extra_keys)) { + for (binding in profile.keys) { + ActionRow(binding.key.label, if (binding.action is InputAction.Native) stringResource(R.string.input_normal_binding) else actionLabel(binding.action), stringResource(R.string.ctrl_edit), { editKey = binding.key }) + TextButton(onClick = { + profile = profile.copy(keys = profile.keys - binding); InputProfiles.save(context, profile); onChanged() + }) { Text(stringResource(R.string.input_remove_binding)) } + } + ActionRow(stringResource(R.string.input_capture), stringResource(R.string.input_capture_hint), stringResource(R.string.ctrl_edit), { capture = true }) + } + SettingsGroup(stringResource(R.string.input_latency)) { + Text(stringResource(R.string.input_latency_hint)) + Row { + Switch(measuring, onCheckedChange = { measuring = it; InputTiming.enabled = it; if (it) InputTiming.reset() }) + Text(stringResource(R.string.input_measure)) + } + Text(timing) + TextButton(onClick = { timing = InputTiming.report(); android.util.Log.i("InputTiming", timing) }) { Text(stringResource(R.string.input_read_timing)) } + } + SettingsGroup(stringResource(R.string.input_surface)) { + ActionRow(stringResource(R.string.input_surface_edit), stringResource(R.string.input_surface_hint), stringResource(R.string.ctrl_edit), { surface = true }, modifier = Modifier.testTag("input-surface-edit")) + ActionRow(stringResource(R.string.ctrl_reset_mapping), null, stringResource(R.string.ctrl_reset), { + InputProfiles.reset(context, id); profile = InputProfiles.read(context, id); onChanged() + }) + } + } + edit?.let { control -> + InputActionDialog(control, profile.action(role, control), onDismiss = { edit = null }) { action -> + profile = profile.withBinding(role, control, action) + InputProfiles.save(context, profile) + edit = null + onChanged() + } + } + if (capture) InputCaptureDialog(onDismiss = { capture = false }) { capture = false; editKey = it } + editKey?.let { key -> InputActionDialog(InputControl.A, profile.keys.firstOrNull { it.key == key }?.action ?: InputAction.Disabled, + onDismiss = { editKey = null }, nativeTargets = false, title = key.label, onPick = { action -> + profile = profile.copy(keys = profile.keys.filterNot { it.key == key } + PhysicalInputBinding(key, action)) + InputProfiles.save(context, profile); editKey = null; onChanged() + }) } + if (surface) InputSurfaceDialog(id, onDismiss = { surface = false }, onChanged) +} + +@Composable +private fun actionLabel(action: InputAction): String { + val context = LocalContext.current + return when (action) { + is InputAction.Native -> action.target?.let { stringResource(R.string.input_native_target, it.label) } ?: stringResource(R.string.input_native) + InputAction.Disabled -> stringResource(R.string.input_disabled) + is InputAction.Keys -> action.codes.joinToString(" + ") { pcKeyLabel(context, it) } + is InputAction.MouseButton -> stringResource(when (action.code) { 272 -> R.string.input_mouse_left; 273 -> R.string.input_mouse_right; else -> R.string.input_mouse_middle }) + is InputAction.Command -> commandLabel(action.command) + is InputAction.Directions -> listOf(action.up, action.right, action.down, action.left).joinToString(" / ") { pcKeyLabel(context, it) } + is InputAction.Pointer -> stringResource(R.string.input_pointer) + is InputAction.Scroll -> stringResource(R.string.input_scroll) + } +} + +@Composable +private fun commandLabel(command: InputCommand) = stringResource(when (command) { + InputCommand.MENU -> R.string.input_command_menu + InputCommand.KEYBOARD -> R.string.input_command_keyboard + InputCommand.STEAM -> R.string.input_command_steam + InputCommand.QUICK_ACCESS -> R.string.input_command_qam +}) + +@Composable +private fun InputActionDialog(control: InputControl, current: InputAction, onDismiss: () -> Unit, nativeTargets: Boolean = true, title: String = control.label, onPick: (InputAction) -> Unit) { + var keys by remember { mutableStateOf(false) } + var directions by remember { mutableStateOf(false) } + var native by remember { mutableStateOf(false) } + var speed by remember { mutableFloatStateOf(when (current) { is InputAction.Pointer -> current.speed; else -> 900f }) } + AlertDialog(onDismissRequest = onDismiss, title = { Text(title) }, confirmButton = {}, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.gesture_cancel)) } }, + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + TextButton(onClick = { onPick(InputAction.Native()) }) { Text(stringResource(if (nativeTargets) R.string.input_native else R.string.input_normal_binding)) } + if (nativeTargets) TextButton(onClick = { native = true }) { Text(stringResource(R.string.input_remap_native)) } + TextButton(onClick = { onPick(InputAction.Disabled) }) { Text(stringResource(R.string.input_disabled)) } + HorizontalDivider() + TextButton(onClick = { keys = true }) { Text(stringResource(R.string.input_keys)) } + if (control.dimensions == 2) { + Text(stringResource(R.string.input_pointer_speed, speed.toInt())) + Slider(value = speed, onValueChange = { speed = it }, valueRange = 100f..3000f) + TextButton(onClick = { onPick(InputAction.Pointer(speed)) }) { Text(stringResource(R.string.input_pointer)) } + TextButton(onClick = { onPick(InputAction.Scroll()) }) { Text(stringResource(R.string.input_scroll)) } + TextButton(onClick = { directions = true }) { Text(stringResource(R.string.input_direction_keys)) } + } + for (code in 272..274) TextButton(onClick = { onPick(InputAction.MouseButton(code)) }) { Text(actionLabel(InputAction.MouseButton(code))) } + for (command in InputCommand.entries) TextButton(onClick = { onPick(InputAction.Command(command)) }) { Text(commandLabel(command)) } + } + }) + if (keys) InputKeysDialog((current as? InputAction.Keys)?.codes.orEmpty(), onDismiss = { keys = false }, onPick = { onPick(InputAction.Keys(it)) }) + if (native) AlertDialog(onDismissRequest = { native = false }, title = { Text(stringResource(R.string.input_remap_native)) }, confirmButton = {}, + text = { Column(Modifier.verticalScroll(rememberScrollState())) { + for (target in InputControl.entries) if (InputAction.Native(target).validFor(control)) { + TextButton(onClick = { onPick(InputAction.Native(target)) }) { Text(target.label) } + } + } }) + if (directions) InputDirectionsDialog((current as? InputAction.Directions) ?: InputAction.Directions(103, 106, 108, 105), + onDismiss = { directions = false }, onPick = onPick) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun InputKeysDialog(initial: List, onDismiss: () -> Unit, onPick: (List) -> Unit, single: Boolean = false) { + val context = LocalContext.current + var codes by remember { mutableStateOf(initial) } + AlertDialog(onDismissRequest = onDismiss, title = { Text(stringResource(R.string.input_keys)) }, + confirmButton = { TextButton(enabled = codes.isNotEmpty(), onClick = { + onPick(codes.sortedBy { if (it in PC_MODIFIERS) 0 else 1 }) + }) { Text(stringResource(R.string.gesture_save_changes)) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.gesture_cancel)) } }, + text = { Column(Modifier.verticalScroll(rememberScrollState())) { + Text(codes.joinToString(" + ") { pcKeyLabel(context, it) }) + FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + for (key in PC_KEYS.values) FilterChip(selected = key.code in codes, onClick = { + codes = if (single) listOf(key.code) else if (key.code in codes) codes - key.code else (codes + key.code).take(8) + }, label = { Text(pcKeyLabel(context, key.code)) }) + } + } }) +} + +@Composable +private fun InputDirectionsDialog(initial: InputAction.Directions, onDismiss: () -> Unit, onPick: (InputAction) -> Unit) { + val context = LocalContext.current + var codes by remember { mutableStateOf(listOf(initial.up, initial.right, initial.down, initial.left)) } + var editing by remember { mutableStateOf(null) } + AlertDialog(onDismissRequest = onDismiss, title = { Text(stringResource(R.string.input_direction_keys)) }, + confirmButton = { TextButton(onClick = { onPick(InputAction.Directions(codes[0], codes[1], codes[2], codes[3])) }) { Text(stringResource(R.string.gesture_save_changes)) } }, + text = { Column { + listOf("↑", "→", "↓", "←").forEachIndexed { i, label -> + TextButton(onClick = { editing = i }) { Text("$label ${pcKeyLabel(context, codes[i])}") } + } + TextButton(onClick = { codes = listOf(17, 32, 31, 30) }) { Text("W / D / S / A") } + } }) + editing?.let { i -> InputKeysDialog(listOf(codes[i]), onDismiss = { editing = null }, single = true, onPick = { value -> + codes = codes.toMutableList().also { it[i] = value.first() }; editing = null + }) } +} + +@Composable +private fun InputCaptureDialog(onDismiss: () -> Unit, onPick: (PhysicalInputKey) -> Unit) { + val focus = remember { FocusRequester() } + var captured by remember { mutableStateOf(null) } + AlertDialog(onDismissRequest = onDismiss, title = { Text(stringResource(R.string.input_capture)) }, + modifier = Modifier.onPreviewKeyEvent { + val event = it.nativeKeyEvent + val key = PhysicalInputKey.capture(event) + if (key != null) { + if (event.action == android.view.KeyEvent.ACTION_DOWN && event.repeatCount == 0) captured = key + true + } else false + }.focusRequester(focus).focusable(), + text = { Text(captured?.label ?: stringResource(R.string.input_capture_hint)) }, + confirmButton = { TextButton(enabled = captured != null, onClick = { captured?.let(onPick) }) { Text(stringResource(R.string.ctrl_edit)) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.gesture_cancel)) } }) + LaunchedEffect(Unit) { focus.requestFocus() } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/InputSurfaceDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/InputSurfaceDialog.kt new file mode 100644 index 000000000..c905787ff --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/InputSurfaceDialog.kt @@ -0,0 +1,57 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.droiddeck.launcher.R +import com.droiddeck.launcher.input.* + +@Composable +fun InputSurfaceDialog(profile: String, onDismiss: () -> Unit, onChanged: () -> Unit) { + val context = LocalContext.current + var layout by remember { mutableStateOf(InputSurfaceLayout.read(context, profile)) } + var selected by remember { mutableStateOf(InputControl.A) } + var adding by remember { mutableStateOf(false) } + val element = layout.firstOrNull { it.control == selected } + fun change(next: InputSurfaceElement) { layout = layout.map { if (it.control == next.control) next.bounded() else it } } + AlertDialog(onDismissRequest = onDismiss, title = { Text(stringResource(R.string.input_surface_edit)) }, + confirmButton = { TextButton(onClick = { InputSurfaceLayout.save(context, profile, layout); onChanged(); onDismiss() }) { Text(stringResource(R.string.gesture_save_changes)) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.gesture_cancel)) } }, + text = { Column(Modifier.verticalScroll(rememberScrollState())) { + Text(stringResource(R.string.input_surface_hint)) + BoxWithConstraints(Modifier.fillMaxWidth().height(180.dp).background(MaterialTheme.colorScheme.surfaceVariant)) { + for (e in layout) Surface(Modifier.offset(maxWidth * e.x, maxHeight * e.y).size(maxWidth * e.width, maxHeight * e.height) + .padding(2.dp).clickable { selected = e.control }, + color = if (e.control == selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer) { + Text(e.control.label, Modifier.padding(4.dp), style = MaterialTheme.typography.labelSmall) + } + } + if (element != null) { + Text(element.control.label) + Text(stringResource(R.string.input_position_x)); Slider(element.x, { change(element.copy(x = it)) }, valueRange = 0f..0.9f) + Text(stringResource(R.string.input_position_y)); Slider(element.y, { change(element.copy(y = it)) }, valueRange = 0f..0.9f) + Text(stringResource(R.string.input_width)); Slider(element.width, { change(element.copy(width = it)) }, valueRange = 0.1f..1f) + Text(stringResource(R.string.input_height)); Slider(element.height, { change(element.copy(height = it)) }, valueRange = 0.1f..1f) + TextButton(onClick = { layout = layout.filter { it.control != selected } }) { Text(stringResource(R.string.input_remove_element)) } + } + TextButton(onClick = { adding = true }) { Text(stringResource(R.string.input_add_element)) } + TextButton(onClick = { layout = InputSurfaceLayout.defaults() }) { Text(stringResource(R.string.ctrl_reset)) } + } }) + if (adding) AlertDialog(onDismissRequest = { adding = false }, title = { Text(stringResource(R.string.input_add_element)) }, confirmButton = {}, + text = { Column(Modifier.verticalScroll(rememberScrollState())) { + for (control in InputControl.entries) if (layout.none { it.control == control }) { + TextButton(onClick = { + layout = layout + InputSurfaceElement(control, 0.1f, 0.1f, if (control.dimensions == 2) 0.4f else 0.2f, 0.3f) + selected = control; adding = false + }) { Text(control.label) } + } + } }) +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt b/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt index 0fc576277..3ac364081 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt @@ -1,5 +1,8 @@ package com.droiddeck.launcher.ui +import com.droiddeck.launcher.input.PcKey +import com.droiddeck.launcher.input.PC_KEY_ROWS +import com.droiddeck.launcher.input.PC_MODIFIERS import com.droiddeck.launcher.R import androidx.annotation.StringRes import androidx.compose.ui.res.stringResource @@ -54,46 +57,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -private data class PcKey(val label: String, val code: Int, val weight: Float = 1f, val shifted: String? = null, @StringRes val labelRes: Int = 0) - -private fun k(label: String, code: Int, weight: Float = 1f, shifted: String? = null) = PcKey(label, code, weight, shifted) - -private fun k(@StringRes labelRes: Int, code: Int, weight: Float = 1f) = PcKey("", code, weight, null, labelRes) - -private val ROWS: List> = listOf( - listOf( - k(R.string.pckb_key_esc, 1), k("F1", 59), k("F2", 60), k("F3", 61), k("F4", 62), k("F5", 63), k("F6", 64), - k("F7", 65), k("F8", 66), k("F9", 67), k("F10", 68), k("F11", 87), k("F12", 88), - k(R.string.pckb_key_ins, 110), k(R.string.pckb_key_del, 111), k(R.string.pckb_key_home, 102), k(R.string.pckb_key_end, 107), k(R.string.pckb_key_pgup, 104), k(R.string.pckb_key_pgdn, 109), - ), - listOf( - k("`", 41, shifted = "~"), k("1", 2, shifted = "!"), k("2", 3, shifted = "@"), k("3", 4, shifted = "#"), - k("4", 5, shifted = "$"), k("5", 6, shifted = "%"), k("6", 7, shifted = "^"), k("7", 8, shifted = "&"), - k("8", 9, shifted = "*"), k("9", 10, shifted = "("), k("0", 11, shifted = ")"), k("-", 12, shifted = "_"), - k("=", 13, shifted = "+"), k(R.string.pckb_key_backspace, 14, 2f), - ), - listOf( - k(R.string.pckb_key_tab, 15, 1.5f), k("q", 16), k("w", 17), k("e", 18), k("r", 19), k("t", 20), k("y", 21), - k("u", 22), k("i", 23), k("o", 24), k("p", 25), k("[", 26, shifted = "{"), k("]", 27, shifted = "}"), - k("\\", 43, 1.5f, shifted = "|"), - ), - listOf( - k(R.string.pckb_key_caps, 58, 1.75f), k("a", 30), k("s", 31), k("d", 32), k("f", 33), k("g", 34), k("h", 35), - k("j", 36), k("k", 37), k("l", 38), k(";", 39, shifted = ":"), k("'", 40, shifted = "\""), - k(R.string.pckb_key_enter, 28, 2.25f), - ), - listOf( - k(R.string.pckb_key_shift, 42, 2.25f), k("z", 44), k("x", 45), k("c", 46), k("v", 47), k("b", 48), k("n", 49), - k("m", 50), k(",", 51, shifted = "<"), k(".", 52, shifted = ">"), k("/", 53, shifted = "?"), - k(R.string.pckb_key_shift, 54, 1.75f), k("↑", 103), - ), - listOf( - k(R.string.pckb_key_ctrl, 29, 1.5f), k(R.string.pckb_key_super, 125, 1.25f), k(R.string.pckb_key_alt, 56, 1.25f), k(R.string.pckb_key_space, 57, 6.25f), - k(R.string.pckb_key_altgr, 100, 1.25f), k(R.string.pckb_key_ctrl, 97, 1.25f), k("←", 105), k("↓", 108), k("→", 106), - ), -) - -private val MODIFIERS = setOf(42, 54, 29, 97, 56, 100, 125) private const val MOD_OFF = 0 private const val MOD_ONCE = 1 private const val MOD_LOCKED = 2 @@ -115,7 +78,7 @@ fun PcKeyboard( for ((code, state) in mods.toMap()) if (state == MOD_ONCE) { sendKey(code, false); mods[code] = MOD_OFF } } fun press(key: PcKey) { - if (key.code in MODIFIERS) { + if (key.code in PC_MODIFIERS) { when (mods[key.code] ?: MOD_OFF) { MOD_OFF -> { sendKey(key.code, true); mods[key.code] = MOD_ONCE } MOD_ONCE -> mods[key.code] = MOD_LOCKED @@ -126,7 +89,7 @@ fun PcKeyboard( sendKey(key.code, true) } fun release(key: PcKey) { - if (key.code in MODIFIERS) return + if (key.code in PC_MODIFIERS) return sendKey(key.code, false) if (key.code == 58) capsOn = !capsOn releaseOnceModifiers() @@ -153,7 +116,7 @@ fun PcKeyboard( Spacer(Modifier.width(8.dp)) HeaderButton(stringResource(R.string.pckb_hide), onClose) } - ROWS.forEachIndexed { r, row -> + PC_KEY_ROWS.forEachIndexed { r, row -> Row(horizontalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.fillMaxWidth().height(rowHeight)) { row.forEachIndexed { i, key -> val state = mods[key.code] ?: MOD_OFF diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt b/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt index 04809ea5c..2ed7b1173 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt @@ -27,6 +27,8 @@ import com.droiddeck.launcher.input.PadBridge import com.droiddeck.launcher.input.PointerGestures import com.droiddeck.launcher.input.SecondScreenMode import com.droiddeck.launcher.input.TouchpadGestures +import com.droiddeck.launcher.input.InputProfiles +import com.droiddeck.launcher.input.InputSurfaceLayout import com.droiddeck.launcher.SessionActivity import com.droiddeck.launcher.session.SessionService import com.droiddeck.launcher.session.SessionState @@ -92,12 +94,27 @@ class SecondScreenPresentation( } override fun dispatchKeyEvent(event: KeyEvent): Boolean { - if (currentMode == SecondScreenMode.KEYBOARD_TRACKPAD) { + if (currentMode != SecondScreenMode.TERMINAL) { if (sessionActivity.dispatchKeyEvent(event)) return true } return super.dispatchKeyEvent(event) } + override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { + if (currentMode != SecondScreenMode.TERMINAL && PadBridge.isControllerMotionEvent(event)) { + return sessionActivity.dispatchGenericMotionEvent(event) + } + return super.dispatchGenericMotionEvent(event) + } + + fun refreshControls() { if (created) renderMode() } + + fun showKeyboard(): Boolean { + val host = keyboardHost ?: return false + host.show() + return true + } + override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus) if (hasFocus) { @@ -119,6 +136,12 @@ class SecondScreenPresentation( SecondScreenMode.KEYBOARD_TRACKPAD -> renderKeyboardAndTrackpad() SecondScreenMode.TERMINAL -> renderTerminal() SecondScreenMode.DECK_CONTROLS -> root.addView(DeckControlsPanel(context, onClose, onSteamMenu, onQam, pad), weightParams()) + SecondScreenMode.CUSTOM_CONTROLS -> { + val profile = InputProfiles.active(context, SessionState.mode == SessionService.MODE_DESKTOP) + root.addView(DeckControlsPanel(context, onClose, onSteamMenu, onQam, pad, + InputSurfaceLayout.read(context, profile.id)), weightParams()) + keyboardHost = KeyboardHost(context) { sessionActivity.dispatchKeyEvent(it) }.also { root.addView(it, ViewGroup.LayoutParams(1, 1)) } + } SecondScreenMode.NONE -> Unit } } 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 58b4c11ee..d55bf95e9 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -257,6 +257,7 @@ class DrawerActions( val onLowLatencyController: (Boolean) -> Unit = {}, val onLowLatencyTouch: (Boolean) -> Unit = {}, val onTouch: (String) -> Unit, + val onInputSettings: () -> Unit = {}, val onGestureSettings: () -> Unit = {}, val onFexPreset: (String) -> Unit, val onUpscaler: (Int) -> Unit = {}, @@ -546,6 +547,8 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont ChoiceRow(host, "touch", stringResource(R.string.mode_touch), null, listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.drawer_touch_auto, a.touchAuto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_GESTURES to stringResource(R.string.mode_touch_gestures), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), a.touchMode, chipModifier = focus.track(page, "touch"), onPick = a.onTouch) + ActionRow(stringResource(R.string.input_settings), null, stringResource(R.string.ctrl_edit), a.onInputSettings, + modifier = focus.track(page, "input-settings")) ActionRow(stringResource(R.string.gesture_settings), stringResource(R.string.gesture_settings_hint), stringResource(R.string.ctrl_edit), a.onGestureSettings, modifier = focus.track(page, "gesture-settings")) @@ -1051,7 +1054,7 @@ private fun SteamVrRow(a: DrawerActions) { private fun SecondScreenGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current - val modes = listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL) + + val modes = listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.CUSTOM_CONTROLS, SecondScreenMode.TERMINAL) + if (com.droiddeck.launcher.session.SessionState.deckPad) listOf(SecondScreenMode.DECK_CONTROLS) else emptyList() SettingsGroup(stringResource(R.string.drawer_second_screen_mode)) { Column(Modifier.fillMaxWidth().selectableGroup()) { diff --git a/app/src/main/res/values/input_strings.xml b/app/src/main/res/values/input_strings.xml new file mode 100644 index 000000000..5b8c9b167 --- /dev/null +++ b/app/src/main/res/values/input_strings.xml @@ -0,0 +1,50 @@ + + Input profiles + Choose what each control sends. Native controls keep their gamepad or Steam Input behavior; local actions send keys, mouse input or DroidDeck commands. + Profiles + Game + Desktop + Active profile + Auto uses Desktop in desktop sessions and Game in other sessions. Select Game manually when playing a game from the desktop. + Edit profile + Control source + Physical controller + Main screen + Second screen + Bindings + Native / Steam Input + Native: %1$s + Send another controller control + Disabled + Key or key combination + Directional keys + Move pointer + Pointer speed: %1$d + Scroll vertically + Left mouse button + Right mouse button + Middle mouse button + Open DroidDeck menu + Show keyboard + Open Steam menu + Open Steam quick access + Screen layout + Custom second-screen layout + Arrange controls here, then select Custom controls in the second-screen panel. Their actions come from this profile’s Second screen bindings. Native trackpads require a Steam Deck controller; Steam Mobile Touch layouts remain configured in Steam. + Horizontal position + Vertical position + Width + Height + Remove control + Add control + Custom controls + Device-specific buttons + Assign a physical button + Press a button, including a back button reported as a keyboard key. Volume and Android system keys are reserved. The assignment applies only to this device. + Remove assignment + Input diagnostics + Record input dispatch time + Read measurements + Measures Android dispatch through publication to the existing input transport. This excludes Bluetooth arrival, guest polling, rendering and display latency. Recording is off by default. + Use normal input binding + diff --git a/app/src/test/java/com/droiddeck/launcher/input/InputProfilesTest.kt b/app/src/test/java/com/droiddeck/launcher/input/InputProfilesTest.kt new file mode 100644 index 000000000..92cdcaa19 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/InputProfilesTest.kt @@ -0,0 +1,64 @@ +package com.droiddeck.launcher.input + +import android.content.Context +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], manifest = Config.NONE) +class InputProfilesTest { + private val context: Context get() = RuntimeEnvironment.getApplication() + @Before fun reset() { + context.getSharedPreferences("input-profiles", Context.MODE_PRIVATE).edit().clear().commit() + context.getSharedPreferences("controller", Context.MODE_PRIVATE).edit().clear().commit() + context.getSharedPreferences("input-surfaces", Context.MODE_PRIVATE).edit().clear().commit() + } + + @Test fun migrationKeepsLegacyTouchMappingSeparateFromPhysicalControls() { + ControllerPrefs.setTarget(context, "a", "b") + ControllerPrefs.setTarget(context, "x", "off") + ControllerPrefs.setSelectSteam(context, true) + val game = InputProfiles.read(context, InputProfile.GAME) + assertEquals(InputAction.Native(InputControl.B), game.action(InputRole.PRIMARY, InputControl.A)) + assertEquals(InputAction.Disabled, game.action(InputRole.PRIMARY, InputControl.X)) + assertEquals(InputAction.Native(), game.action(InputRole.PHYSICAL, InputControl.A)) + assertEquals(InputAction.Native(InputControl.GUIDE), game.action(InputRole.PHYSICAL, InputControl.SELECT)) + InputProfiles.reset(context, InputProfile.GAME) + assertEquals(InputAction.Native(), InputProfiles.read(context, InputProfile.GAME).action(InputRole.PRIMARY, InputControl.A)) + } + + @Test fun bindingsAndDeviceKeysRoundTripWithoutAffectingOtherProfile() { + val game = InputProfile.defaults(InputProfile.GAME) + .withBinding(InputRole.SECONDARY, InputControl.A, InputAction.Keys(listOf(29, 30))) + .copy(keys = listOf(PhysicalInputBinding(PhysicalInputKey("1:2:descriptor", "Thor", 131, 59), InputAction.Command(InputCommand.MENU)))) + InputProfiles.save(context, game) + assertEquals(game, InputProfiles.read(context, InputProfile.GAME)) + assertEquals(InputAction.Pointer(), InputProfiles.read(context, InputProfile.DESKTOP).action(InputRole.PHYSICAL, InputControl.LS)) + } + + @Test fun profileSelectionIsExplicitAndNeverDependsOnDisplayPresence() { + assertEquals(InputProfile.DESKTOP, InputProfiles.active(context, true).id) + assertEquals(InputProfile.GAME, InputProfiles.active(context, false).id) + InputProfiles.select(context, InputProfile.GAME) + assertEquals(InputProfile.GAME, InputProfiles.active(context, true).id) + } + + @Test fun unknownVersionDoesNotExecuteBindings() { + context.getSharedPreferences("input-profiles", Context.MODE_PRIVATE).edit() + .putString("profile.game", """{"version":999,"bindings":[]} """).commit() + assertEquals(InputProfile.defaults(InputProfile.GAME), InputProfiles.read(context, InputProfile.GAME)) + } + + @Test fun layoutsPersistIndependentOfBindingsAndStayInsideScreen() { + InputSurfaceLayout.save(context, InputProfile.GAME, listOf(InputSurfaceElement(InputControl.A, .95f, -.1f, 2f, 2f))) + val element = InputSurfaceLayout.read(context, InputProfile.GAME).single() + assertEquals(.9f, element.x); assertEquals(0f, element.y) + assertTrue(element.x + element.width <= 1f) + assertEquals(InputAction.Native(), InputProfiles.read(context, InputProfile.GAME).action(InputRole.SECONDARY, InputControl.A)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/InputRouterTest.kt b/app/src/test/java/com/droiddeck/launcher/input/InputRouterTest.kt new file mode 100644 index 000000000..709861d5e --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/InputRouterTest.kt @@ -0,0 +1,155 @@ +package com.droiddeck.launcher.input + +import org.junit.Assert.* +import org.junit.Test + +class InputRouterTest { + private class Output : InputOutput { + val events = mutableListOf() + override fun key(code: Int, down: Boolean) { events += "key $code $down" } + override fun button(code: Int, down: Boolean) { events += "button $code $down" } + override fun pointer(dx: Float, dy: Float) { events += "pointer $dx $dy" } + override fun scroll(steps: Int) { events += "scroll $steps" } + override fun command(command: InputCommand) { events += command.name } + override fun requestMotion() { events += "motion" } + } + private val output = Output() + private val router = InputRouter(output) + private fun bind(control: InputControl, action: InputAction, role: InputRole = InputRole.PHYSICAL) = + InputProfile.defaults(InputProfile.GAME).withBinding(role, control, action) + + @Test fun nativeProfileReturnsOriginalStateWithoutScheduling() { + val source = router.pad(InputRole.PHYSICAL) + val state = PadState().apply { leftX = .7f; press(PadState.A, true) } + repeat(1000) { assertSame(state, source.route(state)) } + assertTrue(output.events.isEmpty()) + } + + @Test fun localBindingConsumesOnlyItsOwnNativeControlAndReleasesModifiersLast() { + router.setProfile(bind(InputControl.A, InputAction.Keys(listOf(29, 30)))) + val source = router.pad(InputRole.PHYSICAL) + val state = PadState().apply { press(PadState.A, true); press(PadState.B, true); leftX = .4f } + val routed = source.route(state) + assertFalse(routed.isDown(PadState.A)); assertTrue(routed.isDown(PadState.B)) + assertEquals(.4f, routed.leftX) + source.route(state) + state.press(PadState.A, false); source.route(state) + assertEquals(listOf("key 29 true", "key 30 true", "key 30 false", "key 29 false"), output.events) + } + + @Test fun twoSourcesHoldingOneKeyDoNotReleaseEachOther() { + val p = bind(InputControl.A, InputAction.Keys(listOf(57))) + .withBinding(InputRole.SECONDARY, InputControl.B, InputAction.Keys(listOf(57))) + router.setProfile(p) + val physical = router.pad(InputRole.PHYSICAL) + val touch = router.pad(InputRole.SECONDARY) + physical.route(PadState().apply { press(PadState.A, true) }) + touch.route(PadState().apply { press(PadState.B, true) }) + touch.close() + assertEquals(listOf("key 57 true"), output.events) + physical.release() + assertEquals(listOf("key 57 true", "key 57 false"), output.events) + } + + @Test fun remapCombinesHeldTargetsRatherThanLastButtonWinning() { + router.setProfile(bind(InputControl.A, InputAction.Native(InputControl.B))) + val source = router.pad(InputRole.PHYSICAL) + val raw = PadState().apply { press(PadState.A, true) } + assertTrue(source.route(raw).isDown(PadState.B)) + raw.press(PadState.B, true); raw.press(PadState.A, false) + assertTrue(source.route(raw).isDown(PadState.B)) + raw.press(PadState.B, false) + assertFalse(source.route(raw).isDown(PadState.B)) + } + + @Test fun profileChangeBlocksHeldNativeInputUntilNeutral() { + val source = router.pad(InputRole.PHYSICAL) + val raw = PadState().apply { press(PadState.A, true) } + source.route(raw) + router.setProfile(bind(InputControl.A, InputAction.Keys(listOf(1)))) + source.route(raw) + assertTrue(output.events.isEmpty()) + raw.press(PadState.A, false); source.route(raw) + raw.press(PadState.A, true); source.route(raw) + assertEquals(listOf("key 1 true"), output.events) + } + + @Test fun changingLocalBindingReleasesOldKeyWithoutPressingNewOne() { + router.setProfile(bind(InputControl.A, InputAction.Keys(listOf(1)))) + val source = router.pad(InputRole.PHYSICAL) + val raw = PadState().apply { press(PadState.A, true) } + source.route(raw) + router.setProfile(bind(InputControl.A, InputAction.Keys(listOf(28)))) + source.route(raw) + assertEquals(listOf("key 1 true", "key 1 false"), output.events) + } + + @Test fun triggersUseHysteresisAndCommandsFireOnce() { + router.setProfile(bind(InputControl.LT, InputAction.Command(InputCommand.MENU))) + val source = router.pad(InputRole.PHYSICAL) + val raw = PadState() + for (value in listOf(.6f, .5f, .4f, .6f, .3f, .6f)) { raw.leftTrigger = value; source.route(raw) } + assertEquals(listOf("MENU", "MENU"), output.events) + } + + @Test fun pointerTicksOnlyWhileMovingAndOrdinaryKeysDoNotWaitForTick() { + router.setProfile(InputProfile.defaults(InputProfile.DESKTOP)) + val source = router.pad(InputRole.PHYSICAL) + val raw = PadState().apply { leftX = 1f; press(PadState.X, true) } + source.route(raw) + assertTrue(output.events.contains("key 28 true")) + assertFalse(output.events.any { it.startsWith("pointer") }) + assertTrue(router.tick(.01f)); assertTrue(output.events.contains("pointer 9.0 0.0")) + raw.clear(); source.route(raw) + assertFalse(router.tick(.01f)) + } + + @Test fun trackpadUsesContactRelativeDeltasWithoutFirstTouchJump() { + router.setProfile(bind(InputControl.RIGHT_PAD, InputAction.Pointer(1000f), InputRole.SECONDARY)) + val channel = router.channel(InputRole.SECONDARY, InputControl.RIGHT_PAD) + assertNull(channel.update(.5f, .5f, true)) + assertTrue(output.events.isEmpty()) + channel.update(.6f, .6f, true) + assertEquals(1, output.events.size) + assertTrue(output.events[0].startsWith("pointer 100.")) + channel.update(0f, 0f, false) + channel.update(-.9f, -.9f, true) + assertEquals(1, output.events.size) + } + + @Test fun disablingRoutingReleasesKeysAndBlocksHeldInputOnResume() { + router.setProfile(bind(InputControl.A, InputAction.Keys(listOf(1)))) + val source = router.pad(InputRole.PHYSICAL) + val raw = PadState().apply { press(PadState.A, true) } + source.route(raw); router.setEnabled(false); source.route(raw); router.setEnabled(true); source.route(raw) + assertEquals(listOf("key 1 true", "key 1 false"), output.events) + raw.clear(); source.route(raw); raw.press(PadState.A, true); source.route(raw) + assertEquals("key 1 true", output.events.last()) + } + + @Test fun disabledSourcesDoNotLeakNativeButtons() { + val source = router.pad(InputRole.PHYSICAL) + router.setEnabled(false) + assertFalse(source.route(PadState().apply { press(PadState.A, true) }).isDown(PadState.A)) + } + + @Test fun lifecycleReleaseAlsoBlocksNativeIdentityRoute() { + val source = router.pad(InputRole.PHYSICAL) + val raw = PadState().apply { press(PadState.A, true) } + source.route(raw); router.releaseAll() + assertFalse(source.route(raw).isDown(PadState.A)) + } + + @Test fun mergedPadKeepsPhysicalButtonAndWholeStickVector() { + val physical = PadState().apply { press(PadState.A, true); leftX = .8f; leftY = -.6f } + physical.merge(PadState().apply { leftX = -.2f; leftY = .1f }) + assertTrue(physical.isDown(PadState.A)); assertEquals(.8f, physical.leftX); assertEquals(-.6f, physical.leftY) + } + + @Test fun outputOwnershipKeepsKeyboardAndMappingHoldsIndependent() { + val held = HeldInput(output::key) + held.set(1, 29, true); held.set(4, 29, true); held.release(4) + assertEquals(listOf("key 29 true"), output.events) + held.clear(); assertEquals("key 29 false", output.events.last()) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/InputSourceOwnershipTest.kt b/app/src/test/java/com/droiddeck/launcher/input/InputSourceOwnershipTest.kt new file mode 100644 index 000000000..36ccad1d2 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/InputSourceOwnershipTest.kt @@ -0,0 +1,66 @@ +package com.droiddeck.launcher.input + +import java.io.File +import org.junit.Assert.* +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], manifest = Config.NONE, shadows = [JoyConPadTest.RecordingWriter::class]) +class InputSourceOwnershipTest { + private val bridge = PadBridge(File(RuntimeEnvironment.getApplication().cacheDir, "input-ownership")) + private fun state() = JoyConPadTest.RecordingWriter.pads[0]!! + @After fun close() { bridge.stop(); DeckControls.releaseAll(); DeckControls.setRouter(null) } + + @Test fun closingOneSurfaceDoesNotReleaseAnotherSurfaceOrPhysicalPad() { + val a = Any(); val b = Any() + bridge.applyExternal({ it.press(PadState.A, true) }, true) + bridge.applyTouch(a, InputRole.PRIMARY) { it.press(PadState.A, true); it.press(PadState.B, true) } + bridge.applyTouch(b, InputRole.SECONDARY) { it.press(PadState.X, true) } + bridge.releaseTouch(a) + assertTrue(state().isDown(PadState.A)); assertTrue(state().isDown(PadState.X)); assertFalse(state().isDown(PadState.B)) + bridge.releaseTouch(b) + assertTrue(state().isDown(PadState.A)); assertFalse(state().isDown(PadState.X)) + } + + @Test fun disconnectingSecondaryDeckSurfaceKeepsPhysicalGripHeld() { + val surface = DeckControls.source(InputRole.SECONDARY) + DeckControls.setGrip(DeckControls.L4, true) + surface.setGrip(DeckControls.L4, true) + surface.close() + assertEquals(DeckControls.L4, JoyConPadTest.RecordingWriter.grips) + DeckControls.setGrip(DeckControls.L4, false) + assertEquals(0, JoyConPadTest.RecordingWriter.grips) + } + + @Test fun nativePadAndTouchMappingsUseDifferentRoles() { + val output = object : InputOutput { + override fun key(code: Int, down: Boolean) {} + override fun button(code: Int, down: Boolean) {} + override fun pointer(dx: Float, dy: Float) {} + override fun scroll(steps: Int) {} + override fun command(command: InputCommand) {} + override fun requestMotion() {} + } + val router = InputRouter(output) + bridge.setInputRouter(router) + bridge.setInputProfile(InputProfile.defaults(InputProfile.GAME).withBinding(InputRole.SECONDARY, InputControl.A, InputAction.Disabled)) + val touch = Any() + bridge.applyTouch(touch, InputRole.SECONDARY) { it.press(PadState.A, true) } + assertFalse(state().isDown(PadState.A)) + bridge.applyExternal({ it.press(PadState.A, true) }, true) + assertTrue(state().isDown(PadState.A)) + bridge.releaseAll() + assertFalse(state().isDown(PadState.A)) + bridge.applyExternal({ it.press(PadState.B, true) }, true) + assertFalse(state().isDown(PadState.A)) + assertTrue(state().isDown(PadState.B)) + bridge.applyExternal({ it.press(PadState.A, false) }, true) + bridge.applyExternal({ it.press(PadState.A, true) }, true) + assertTrue(state().isDown(PadState.A)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/PhysicalInputBindingsTest.kt b/app/src/test/java/com/droiddeck/launcher/input/PhysicalInputBindingsTest.kt new file mode 100644 index 000000000..71a098578 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/PhysicalInputBindingsTest.kt @@ -0,0 +1,70 @@ +package com.droiddeck.launcher.input + +import android.hardware.input.InputManager +import android.view.InputDevice +import android.view.KeyEvent +import org.junit.Assert.* +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.shadows.InputDeviceBuilder + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], manifest = Config.NONE, shadows = [PadBridgeRoutingTest.RegisteredKeyEvent::class]) +class PhysicalInputBindingsTest { + private val events = ArrayList() + private val router = InputRouter(object : InputOutput { + override fun key(code: Int, down: Boolean) { events += "$code $down" } + override fun button(code: Int, down: Boolean) {} + override fun pointer(dx: Float, dy: Float) {} + override fun scroll(steps: Int) {} + override fun command(command: InputCommand) { events += command.name } + override fun requestMotion() {} + }) + private val bindings = PhysicalInputBindings(router) + + private fun device(id: Int): InputDevice { + val device = InputDeviceBuilder.newBuilder().setId(id).setName("Thor extra keys").setDescriptor("keys-$id") + .setSources(InputDevice.SOURCE_KEYBOARD).setKeyboardType(InputDevice.KEYBOARD_TYPE_NON_ALPHABETIC).build() + val manager = RuntimeEnvironment.getApplication().getSystemService(InputManager::class.java) + shadowOf(manager).addInputDevice(device) + return device + } + private fun event(id: Int, down: Boolean, code: Int = KeyEvent.KEYCODE_F1, repeat: Int = 0) = + KeyEvent(0L, 0L, if (down) KeyEvent.ACTION_DOWN else KeyEvent.ACTION_UP, code, repeat, 0, id, 59, 0, InputDevice.SOURCE_KEYBOARD) + + @Test fun captureReservesSystemKeysButAcceptsKeyboardBackButtons() { + val device = device(40) + assertNotNull(PhysicalInputKey.capture(event(device.id, true))) + assertNull(PhysicalInputKey.capture(event(device.id, true, KeyEvent.KEYCODE_VOLUME_UP))) + assertNull(PhysicalInputKey.capture(event(device.id, true, KeyEvent.KEYCODE_HOME))) + } + + @Test fun bindingMatchesOnlyItsDeviceAndReleasesAfterEditingProfile() { + val device = device(40); val other = device(41) + val key = PhysicalInputKey.capture(event(device.id, true))!! + bindings.configure(listOf(PhysicalInputBinding(key, InputAction.Keys(listOf(29, 30))))) + assertFalse(bindings.onKey(event(other.id, true), true)) + assertTrue(bindings.onKey(event(device.id, true), true)) + bindings.configure(listOf(PhysicalInputBinding(key, InputAction.Keys(listOf(28))))) + assertTrue(bindings.onKey(event(device.id, true, repeat = 1), true)) + assertTrue(bindings.onKey(event(device.id, false), true)) + assertEquals(listOf("29 true", "30 true", "30 false", "29 false"), events) + bindings.onKey(event(device.id, true), true) + assertEquals("28 true", events.last()) + } + + @Test fun releasesRemainConsumedAfterOpeningMenuAndVirtualKeysAreUntouched() { + val device = device(40) + val key = PhysicalInputKey.capture(event(device.id, true))!! + bindings.configure(listOf(PhysicalInputBinding(key, InputAction.Keys(listOf(57))))) + assertFalse(bindings.onKey(event(-1, true), true)) + bindings.onKey(event(device.id, true), true) + bindings.release() + assertTrue(bindings.onKey(event(device.id, false), false)) + assertEquals(listOf("57 true", "57 false"), events) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/TritonInputRouterTest.kt b/app/src/test/java/com/droiddeck/launcher/input/TritonInputRouterTest.kt new file mode 100644 index 000000000..a267d6d8c --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/TritonInputRouterTest.kt @@ -0,0 +1,47 @@ +package com.droiddeck.launcher.input + +import org.junit.Assert.* +import org.junit.Test + +class TritonInputRouterTest { + private val events = ArrayList() + private val router = InputRouter(object : InputOutput { + override fun key(code: Int, down: Boolean) { events += "$code $down" } + override fun button(code: Int, down: Boolean) {} + override fun pointer(dx: Float, dy: Float) {} + override fun scroll(steps: Int) {} + override fun command(command: InputCommand) {} + override fun requestMotion() {} + }) + + @Test fun nativeReportIsPassedThroughWithoutCopyingOrReencoding() { + val adapter = TritonInputRouter(router) + val bytes = ByteArray(64) { (it * 11).toByte() } + assertSame(bytes, adapter.route(bytes)) + assertTrue(events.isEmpty()) + adapter.close() + } + + @Test fun consumedButtonDoesNotChangeOtherBitsAxesPressureMotionOrExtension() { + router.setProfile(InputProfile.defaults(InputProfile.GAME).withBinding(InputRole.PHYSICAL, InputControl.A, InputAction.Keys(listOf(57)))) + val adapter = TritonInputRouter(router) + val bytes = ByteArray(64) { (it * 7).toByte() } + bytes[1] = (bytes[1].toInt() or 1).toByte() + val expected = bytes.copyOf().apply { this[1] = (this[1].toInt() and 1.inv()).toByte() } + assertArrayEquals(expected, adapter.route(bytes)) + assertEquals(listOf("57 true"), events) + adapter.release() + assertEquals(listOf("57 true", "57 false"), events) + } + + @Test fun switchingProfileWhileNativeButtonHeldRequiresRelease() { + val adapter = TritonInputRouter(router) + val held = ByteArray(45).apply { this[1] = 1 } + adapter.route(held) + router.setProfile(InputProfile.defaults(InputProfile.GAME).withBinding(InputRole.PHYSICAL, InputControl.A, InputAction.Keys(listOf(57)))) + adapter.route(held) + assertTrue(events.isEmpty()) + adapter.route(ByteArray(45)); adapter.route(held) + assertEquals(listOf("57 true"), events) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/InputSettingsTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/InputSettingsTest.kt new file mode 100644 index 000000000..ecb0dcf51 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/InputSettingsTest.kt @@ -0,0 +1,52 @@ +package com.droiddeck.launcher.ui + +import android.content.Context +import androidx.compose.ui.test.* +import com.droiddeck.launcher.input.* +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], qualifiers = "w1000dp-h900dp") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class InputSettingsTest { + @get:Rule val compose = createRobolectricComposeRule() + private val context get() = RuntimeEnvironment.getApplication() + @Before fun reset() { + context.getSharedPreferences("input-profiles", Context.MODE_PRIVATE).edit().clear().commit() + context.getSharedPreferences("input-surfaces", Context.MODE_PRIVATE).edit().clear().commit() + context.getSharedPreferences("controller", Context.MODE_PRIVATE).edit().clear().commit() + } + + @Test fun bindingEditorPersistsDisabledWithoutChangingTouchControls() { + var changed = false + compose.setContent { DroidDeckTheme { InputSettingsPage({}, { changed = true }) } } + compose.onNodeWithTag("input-binding-a").performScrollTo().performClick() + compose.onNodeWithText("Disabled", substring = false).performScrollTo().performClick() + compose.runOnIdle { + assertTrue(changed) + val profile = InputProfiles.read(context, InputProfile.GAME) + assertEquals(InputAction.Disabled, profile.action(InputRole.PHYSICAL, InputControl.A)) + assertEquals(InputAction.Native(), profile.action(InputRole.SECONDARY, InputControl.A)) + } + } + + @Test fun customLayoutAddsExistingControlAndKeepsProfileBindings() { + var changed = false + compose.setContent { DroidDeckTheme { InputSurfaceDialog(InputProfile.GAME, {}, { changed = true }) } } + compose.onNodeWithText("Add control").performScrollTo().performClick() + compose.onNodeWithText("R1", substring = false).performScrollTo().performClick() + compose.onNodeWithText("Save changes").performClick() + compose.runOnIdle { + assertTrue(changed) + assertTrue(InputSurfaceLayout.read(context, InputProfile.GAME).any { it.control == InputControl.RB }) + } + } +} diff --git a/docs/development/unified-input-proposal.md b/docs/development/unified-input-proposal.md new file mode 100644 index 000000000..76ee629f0 --- /dev/null +++ b/docs/development/unified-input-proposal.md @@ -0,0 +1,322 @@ +# Unified input configuration and routing proposal + +Status: implemented locally for device evaluation; not published or approved by maintainers. +Prepared on 2026-10-23 against `ac0909ea`. +Related source snapshots: PR #1202 at `42db788f`, PR #1305 at `76fc9b31`. + +## Problem and scope + +DroidDeck has useful input paths whose configuration and lifecycle rules are +spread across several components. Desktop users need configurable physical +controller bindings. Dual-screen users also need to arrange and configure lower +screen controls without giving up Steam Deck trackpads or Steam Input layouts. + +Provide one configuration model and reusable action handling while preserving +the existing low-latency transports and Steam's ownership of Steam-managed +bindings. A common model does not require all input to cross a new queue, UI +thread, generic event bus, or virtual device. + +The implementation has two editable built-in profiles, Game and +Desktop. Named profile creation, per-application selection, and profile +import/export are future work; the stored model must allow them without changing +the routing contract. + +## Existing paths to preserve + +| Source | Current path | Meaning | +| --- | --- | --- | +| Android physical pad | `PadBridge` -> `FakeInputWriter` | Gamepad buttons, axes, player slots | +| Built-in screen pad, including the lower-screen Gamepad tab | `PadBridge.applyTouch` -> `FakeInputWriter` | Virtual gamepad input | +| Lower-screen Deck trackpads and back grips | `DeckControlsPanel` -> `DeckControls` -> `FakeInputWriter.writeDeckControls` | Touch coordinates, contact, click/pressure, L4/L5/R4/R5 | +| Steam touch overlay | `SteamTouchControls` -> `SteamTouchDevice` -> Steam | Steam Mobile Touch reports, with Steam-owned bindings and action sets | +| Desktop trackpad and keyboard | Existing gesture/keyboard handlers -> compositor | Direct guest mouse and keyboard input | +| Steam Controller BLE and proposed native DualSense | Driver-specific report paths | Input can bypass Android `KeyEvent` dispatch | + +Deck trackpads must not be reduced to mouse deltas or a pair of ordinary joystick +axes. Their contact, coordinates, click state and pressure are meaningful to +Steam. Likewise, Steam Mobile Touch is a distinct protocol, not another spelling +of an Xbox pad. + +See [Steam touch controls](steam-touch-controls.md) for the current integration, +including Steam-owned layouts, action sets and cloud configuration. + +## Separate bindings, screen layout, and device calibration + +The proposed model has three independent parts: + +- **Binding profile:** logical input sources and their actions, with Game and + Desktop defaults and user overrides. +- **Surface layout:** which controls appear on the primary or secondary control + surface, their positions, sizes and presentation. A layout references bindings + or Steam-managed controls; it does not copy Steam's action-set definitions. +- **Device configuration:** hardware identification, axis normalization, + calibration and capabilities. This is separate from a user's desktop layout. + +Use logical surface roles rather than persistent Android display IDs. Controls +need stable IDs independent of their location. Moving a control to the lower +screen must not change its action or create a new guest gamepad. The existing +player-slot and device identities remain authoritative. + +A Game profile can use the lower screen for two Deck trackpads and back grips, +plus selected DroidDeck actions. A Desktop profile can put a mouse trackpad and +keyboard there while mapping the physical sticks to arrow keys, scrolling or +other actions. Connecting a second display must not silently rewrite physical +controller bindings. + +The model should support reusable lower-screen layouts later. Initially expose +existing layouts and a bounded set of configurable controls; a complete freeform +multi-display editor is a separate deliverable. + +## Steam Input ownership + +Each binding explicitly selects an execution path: + +1. **Native/Steam-managed:** send the existing controller or touch report. + Steam remains responsible for game bindings, action sets, layers and its + touch/radial menus. DroidDeck shows an appropriate entry into Steam's editor. +2. **DroidDeck-managed:** execute a guest key combination, mouse action or + application action through the existing output functions. The consumed + control is excluded from the normal controller output. +3. **Disabled:** intentionally produce no action. + +Steam-managed and DroidDeck-managed controls can coexist on one surface. One +activation must have one semantic owner, so a local keyboard shortcut does not +also produce the original controller button. Gamepad-to-gamepad remaps remain +explicit preprocessing before the normal game/Steam path, rather than a second +independent action sent alongside it. + +Steam Input already supports extensive binding and touch-menu behavior. This +proposal concentrates DroidDeck's work on Android surfaces, app shortcuts, +desktop/non-Steam bindings and consistent lifecycle handling. It does not propose +reimplementing Steam's configuration engine or importing its profiles into a +second authoritative store. + +Preserve raw-report fields and output/feedback channels not affected by a local +binding, including motion and haptics. Driver-specific adapters must participate +before publishing consumed controls; an Activity-only interceptor cannot cover +raw HID devices. + +## Configuration and runtime model + +Responsibilities: + +| Component | Responsibility | +| --- | --- | +| `InputProfile` | Version, stable ID, default role, bindings and surface-layout references | +| `SurfaceLayout` | Stable control IDs, display role, geometry and appearance | +| `InputBinding` | Source selector, activation rule, action and execution owner | +| Source adapters | Preserve device identity, normalize inputs and expose capabilities | +| Compiled binding plan | Pre-resolved routes for one active profile and its devices | +| Action executor/output ownership | Execute existing outputs and track held contributions | + +Buttons and axis directions can produce keys, key combinations, mouse buttons, +gamepad buttons or app actions. Sticks can retain their gamepad role or produce +pointer velocity, scrolling or keyboard directions. Analog triggers need distinct +press/release thresholds when used as buttons. Capability-aware controls should +explain why an unavailable action cannot run, rather than silently substituting +another behavior. + +The same action picker is reused for physical and local screen controls. Screen +geometry stays outside binding execution. Steam-managed widgets retain their +native configuration and protocol adapters. + +Configuration is loaded, validated and compiled when it changes. Dispatch uses +an immutable plan snapshot and preallocated per-source state. JSON decoding, +preference reads, disk writes and layout traversal do not belong in event dispatch. +Use stable symbolic control/action identifiers in storage; transient Android +device IDs are runtime keys, not portable profile identifiers. + +Profile selection defaults to the session's mode, with an explicit session +override. Desktop mode can contain a game, so it is not sufficient evidence for +automatic keyboard/mouse interception. An optional profile-switch action uses +the same app-action mechanism as menus and keyboard display. + +## Ownership and lifecycle + +- Track held keys and mouse buttons by source. Releasing one source must not + release a key or button another source still owns. +- Cancel only a disappearing surface's or disconnected device's contributions; + avoid a global release that also clears another screen or controller. +- Define arbitration for multiple sources targeting the same analog control or + Deck touch surface. Retain ownership through a contact; do not let concurrent + views overwrite each other's reports or add a second virtual pad implicitly. +- Changing a binding/profile releases its old outputs before installing the new + route. Held controls must return to neutral before triggering a new binding. +- Menus, keyboard capture, pause, focus loss and device removal have explicit + routing rules. Menu navigation retains working confirmation, cancellation and + page controls even when those buttons have gameplay bindings. +- Keep system-reserved Android inputs with the system and retain touchscreen + access to reset a problematic controller assignment. + +Button chords require a deliberate policy. A dedicated modifier can select a +chord without delaying unrelated controls. A button that also has a single-press +action may require deferred dispatch to disambiguate a chord, hold or double tap. +That delay must be opt-in, visible in configuration and limited to the affected +binding. Do not buffer all controller presses to support optional chords. + +## Latency requirements and verification + +The goal is no additional scheduling stage for normal input, not an unmeasured +claim of zero added latency. + +- Identity routes retain the existing direct publication path and report format. +- Ordinary buttons and touch reports must not wait for drawing, Compose state, + a new coroutine, a timer, an event-bus subscriber or another frame. +- Add no hot-path allocations for ordinary binding lookup. Reuse buffers and + keep critical sections short. The current router serializes routing and profile + changes under one monitor to keep shared output ownership consistent; hardware + traces must establish whether this causes contention before publication. +- Continuous stick-to-pointer movement needs a clock even when no new stick + event arrives. Keep that scheduler limited to the active synthesized movement + route; stop it at neutral. It must not schedule ordinary gamepad forwarding. +- Keep existing synchronization and low-latency flags intact until measurements + justify a separate change. Do not weaken shared-memory publication ordering. +- Send UI updates separately from input publication. Marshaling app-menu work to + Android's UI thread must not marshal ordinary controller output with it. + +Before publishing the change, record a baseline on Thor with the same build +configuration, controller mode, display refresh rates and low-latency settings. +Compare identity forwarding, a local key binding, stick-to-pointer, Deck trackpad +input and simultaneous upper/lower-screen input under both idle and rendering +load. Measure p50/p95/p99 dispatch-to-publication time, allocations and contention. +Use debug-only sampling or buffered tracing; avoid per-event log writes. + +Android-to-publication measurements do not measure complete button-to-photon +latency. Guest receipt and rendered response need separate measurement, with +clock-domain alignment for cross-process traces or an external high-speed-camera +test. Record benchmark conditions and uncertainty; establish numerical acceptance +budgets from the baseline before presenting latency claims in a PR. + +Hard acceptance conditions include no new frame/timer wait for identity routes, +no duplicate or lost press/release transitions, and no degradation of current +Deck trackpad behavior. Microbenchmarks alone cannot establish those conditions. + +## Reuse, migration, and removal + +- [#1202](https://github.com/Droid-Deck/DroidDeck/pull/1202): reuse physical capture, + device identification and menu-action UI where suitable. Generalize the + execution contract beyond Activity `KeyEvent` interception. +- [#1305](https://github.com/Droid-Deck/DroidDeck/pull/1305): reuse PC-key definitions, + key selection and screen-control presentation. Adapt keyboard output to shared + ownership; its screen-layout format is not a complete physical-input profile. +- [#581](https://github.com/Droid-Deck/DroidDeck/pull/581): coordinate device + calibration/profile matching, independently of user binding/surface profiles. +- [#1226](https://github.com/Droid-Deck/DroidDeck/pull/1226): use a shared keyboard + action with the appropriate existing host/display, rather than hard-coding a + second keyboard implementation. +- Reuse current guest keyboard/mouse output, `PadBridge`, Deck/Steam touch + transports and the existing second-screen presentation lifecycle. + +Remove duplicated settings, capture UI and action switches only after their +replacement preserves saved settings and passes behavior tests. Do not remove +working device adapters, Steam touch support or native transport code merely to +make the architecture diagram more uniform. Stage each migration so it can be +reviewed and reverted independently. + +## Implemented local evaluation build + +No PR is created yet. Test the complete integrated change on Thor before deciding +whether to publish it. The intended framing is **Shared input profiles and +configurable control surfaces**, with links to the desktop requests +[#860](https://github.com/Droid-Deck/DroidDeck/issues/860) and +[#1356](https://github.com/Droid-Deck/DroidDeck/issues/1356), the centralization +discussion in #1202, and the related work above. Those discussions are context, +not maintainer approval of this implementation. + +### Runtime and configuration + +- `InputProfile` separates actions and source roles from `InputSurfaceLayout`. + `InputProfiles` stores a versioned document and migrates legacy touch mappings + and Select-as-Steam into Game once. Editing one role does not edit another. +- `InputRouter` resolves actions when a profile changes. `PadSource` returns the + original `PadState` on the identity path. Other routes reuse their output + buffers and consume only the selected control. A key combination presses + modifiers first and releases in reverse order. Trigger/direction hysteresis + uses separate press and release thresholds. +- `PadBridge` preserves physical player slots and separately owns each touch + surface. Merging uses OR for buttons, maximum trigger values and the strongest + complete stick vector. Surface removal cannot release another source's hold. +- `DeckControls` keeps per-source contacts, coordinates, clicks and pressure. + Default native actions retain the existing Deck report. Local trackpad actions + use contact-relative deltas, avoiding a jump at first contact. +- `TritonInputRouter` routes Steam Controller BLE reports before publication. Its + identity path returns the original byte array. Other paths preserve packet, + timestamp, inertial data, grip-contact bits, report extensions and pressure. + This adapter is covered by synthetic report tests; physical BLE testing is + still required. +- `PhysicalInputBindings` captures device identity, keycode and scancode, including + controller buttons exposed as keyboard keys. It consumes a configured local + action before normal dispatch and keeps releases paired across profile edits. + Android volume and system keys remain reserved. +- `HeldInput` coordinates local mappings with existing guest keyboard, gesture, + mouse and second-screen outputs. `InputMotion` schedules only synthesized + stick movement/scrolling. Buttons and trackpad deltas publish synchronously; + UI commands use the existing main-thread handler. +- Profile changes, suspension and menu entry release local outputs and require + held inputs to return to neutral before activating their new actions. + +### User interface + +Input profiles are available from Controller setup and the session's Controller +panel. Auto selects Desktop for a desktop session and Game for other sessions; +manual selection handles games launched from a desktop without guessing from +window focus. The editor exposes physical, primary-screen and secondary-screen +roles, native/remapped gamepad output, local actions and disabled bindings. + +Custom controls is an additional second-screen mode. Its editor arranges a +bounded set of existing `DeckControlsPanel` buttons, sticks, trackpads and grips. +Layout geometry is independent of bindings and saved per built-in profile. +Android keyboard input reuses `KeyboardHost`; the existing keyboard/trackpad +and Deck panel modes remain available. Shared PC-key definitions are taken from +PR #1305 at `76fc9b31`; capture/identity handling follows PR #1202 at `42db788f`. +Neither PR is merged wholesale. The superseded touch-only mapping page and +Select-as-Steam UI toggle are removed after migration. + +### Boundaries and deferred work + +Steam Mobile Touch retains its Steam-owned action sets, layouts and cloud data; +its controls are edited in Steam. It does not pass through DroidDeck's local +remapper. Existing gesture recognition and VR controller handling are retained; +gestures share output ownership, while the VR-specific input path remains owned +by VR. Native remaps are limited to compatible controls within the same report +family. An Android physical key can retain its original behavior or run a local +action; capture does not synthesize an unrelated hardware device. + +Named profile libraries, importing/exporting profiles, per-app switching, +reusable layout presets, macros, tap/hold/chord activation and a freeform +multi-display drag editor remain deferred. Key combinations emitted by one +binding are supported; multi-button activation chords are not implemented. +Hardware calibration stays in the existing adapters. No native transport, +Steam haptics path or device driver is replaced. + +### Validation and Thor procedure + +Before changes, the existing controller/PadMouse/on-screen/second-screen tests +passed. The integrated suite exercises routing, shared holds, key order, +hysteresis, neutral gating, profile migration/persistence, raw report preservation, +existing input paths and the Compose binding/layout editors. See the local +Gradle test report for exact results. + +1. Install the local test APK alongside the signed public app; keep runtime data + and baseline configuration available. Do not replace the official signing key. +2. In a Desktop session, test pointer/scroll, mouse buttons, arrow keys, arbitrary + key combinations and captured hardware back buttons. Open and close the drawer + and keyboard with controls held; verify every guest release. +3. Select Game and test Factorio plus Steam UI, first with the default native + profile, then with individual bindings changed to local or disabled. Repeat + with Xbox 360 and Steam Deck virtual-controller modes. +4. Exercise keyboard/trackpad, Deck panels and Custom controls on the lower screen. + Hold the same output from two sources, remove a surface, change profiles while + holding sticks/buttons and reconnect the controller/display. +5. Enable Input diagnostics, close the editor, generate input under idle and + rendering load, then reopen and read p50/p95/p99. Recording is bounded to the + most recent 4096 samples and disabled by default. `InputTiming` measures dispatch + through existing transport publication, including routing. It does not measure + full button-to-photon latency. Use the same instrumentation on a baseline build + before making comparative claims. +6. Record refresh rates, controller/transport mode, build type and low-latency + flags. Compare raw identity, local key, pointer and native Deck routes. Use + external high-speed video or aligned guest traces for end-to-end measurement. + +A passing host test suite does not establish latency on Thor or physical BLE +compatibility. Those remain explicit release gates before a public PR.