diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandler.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandler.kt index 0cc0a6a92f..9856e0d789 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandler.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandler.kt @@ -13,6 +13,7 @@ import com.winlator.inputcontrols.ControlElement import com.winlator.inputcontrols.ControlsProfile import com.winlator.inputcontrols.ExternalController import com.winlator.inputcontrols.ExternalControllerBinding +import com.winlator.inputcontrols.JoyConSupport import com.winlator.math.Mathf import com.winlator.xserver.XServer import java.util.Timer @@ -113,27 +114,28 @@ class PhysicalControllerHandler( */ fun onKeyEvent(event: KeyEvent): Boolean { if (profile != null && event.repeatCount == 0) { + val keyCode = JoyConSupport.remapKeyCode(event.device, event) if (radialMenuPressed && !isRadialMenuOpenerDevice(event.deviceId)) return true val controller = profile?.getController(event.deviceId) if (controller != null) { - val controllerBinding = controller.getControllerBinding(event.keyCode) + val controllerBinding = controller.getControllerBinding(keyCode) if (radialMenuPressed && controllerBinding?.bindingCombo?.bindings?.contains(Binding.OPEN_RADIAL_MENU) == true) { - if (event.keyCode == radialMenuOpenerKeyCode || + if (keyCode == radialMenuOpenerKeyCode || radialMenuOpenerKeyCode == KeyEvent.KEYCODE_UNKNOWN ) { handleInputEvent( controllerBinding.bindingCombo, event.action == KeyEvent.ACTION_DOWN, - sourceKeyCode = event.keyCode, + sourceKeyCode = keyCode, sourceDeviceId = event.deviceId, sourceController = controller, ) return true } - handleRadialMenuNavigationKey(event) + handleRadialMenuNavigationKey(event, keyCode) return true } - if (radialMenuPressed && handleRadialMenuNavigationKey(event)) { + if (radialMenuPressed && handleRadialMenuNavigationKey(event, keyCode)) { return true } @@ -141,9 +143,9 @@ class PhysicalControllerHandler( // Some controllers emit BOTH a digital KeyEvent for L2/R2 and an analog axis value in MotionEvent. // If this physical key is mapped to a virtual trigger AND the device exposes trigger axes, // ignore the KeyEvent to avoid an initial "full press" spike. MotionEvent will provide the analog value. - if ((event.keyCode == KeyEvent.KEYCODE_BUTTON_L2 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R2) && + if ((keyCode == KeyEvent.KEYCODE_BUTTON_L2 || keyCode == KeyEvent.KEYCODE_BUTTON_R2) && controllerBinding.bindingCombo.bindings.any { it == Binding.GAMEPAD_BUTTON_L2 || it == Binding.GAMEPAD_BUTTON_R2 } && - deviceHasTriggerAxis(event.device, event.keyCode) + deviceHasTriggerAxis(event.device, keyCode) ) { return true } @@ -154,7 +156,7 @@ class PhysicalControllerHandler( controllerBinding.bindingCombo, event.action == KeyEvent.ACTION_DOWN, offset, - sourceKeyCode = event.keyCode, + sourceKeyCode = keyCode, sourceDeviceId = event.deviceId, sourceController = controller, ) @@ -839,9 +841,9 @@ class PhysicalControllerHandler( return radialMenuOpenerDeviceId == UNKNOWN_DEVICE_ID || deviceId == radialMenuOpenerDeviceId } - private fun handleRadialMenuNavigationKey(event: KeyEvent): Boolean { + private fun handleRadialMenuNavigationKey(event: KeyEvent, keyCode: Int): Boolean { if (event.action != KeyEvent.ACTION_DOWN) { - return when (event.keyCode) { + return when (keyCode) { KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT, @@ -850,7 +852,7 @@ class PhysicalControllerHandler( else -> false } } - val vector = when (event.keyCode) { + val vector = when (keyCode) { KeyEvent.KEYCODE_DPAD_UP -> 0f to -1f KeyEvent.KEYCODE_DPAD_DOWN -> 0f to 1f KeyEvent.KEYCODE_DPAD_LEFT -> -1f to 0f diff --git a/app/src/main/java/com/winlator/inputcontrols/ControllerManager.java b/app/src/main/java/com/winlator/inputcontrols/ControllerManager.java index 5716191de1..81602017a7 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ControllerManager.java +++ b/app/src/main/java/com/winlator/inputcontrols/ControllerManager.java @@ -224,6 +224,18 @@ public static boolean isGameController(InputDevice device) { */ public static String getDeviceIdentifier(InputDevice device) { if (device == null) return null; + if (JoyConSupport.isJoyCon(device)) { + List connectedDevices = new ArrayList<>(); + for (int deviceId : InputDevice.getDeviceIds()) { + InputDevice connectedDevice = InputDevice.getDevice(deviceId); + if (connectedDevice != null) { + connectedDevices.add(new int[]{connectedDevice.getVendorId(), connectedDevice.getProductId()}); + } + } + if (JoyConSupport.hasExactlyOnePair(connectedDevices)) { + return JoyConSupport.PAIRED_IDENTIFIER; + } + } // The descriptor is the most reliable unique ID for a device. if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { return device.getDescriptor(); diff --git a/app/src/main/java/com/winlator/inputcontrols/ExternalController.java b/app/src/main/java/com/winlator/inputcontrols/ExternalController.java index fef4af5d9a..c82f30393a 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ExternalController.java +++ b/app/src/main/java/com/winlator/inputcontrols/ExternalController.java @@ -180,11 +180,11 @@ public String toString() { private void processJoystickInput(MotionEvent event, int historyPos) { boolean z = false; - this.state.thumbLX = getCenteredAxis(event, MotionEvent.AXIS_X, historyPos); - this.state.thumbLY = getCenteredAxis(event, MotionEvent.AXIS_Y, historyPos); - this.state.thumbRX = getCenteredAxis(event, MotionEvent.AXIS_Z, historyPos); - this.state.thumbRY = getCenteredAxis(event, MotionEvent.AXIS_RZ, historyPos); - if (historyPos == -1) { + this.state.thumbLX = updateAxis(event, MotionEvent.AXIS_X, historyPos, this.state.thumbLX); + this.state.thumbLY = updateAxis(event, MotionEvent.AXIS_Y, historyPos, this.state.thumbLY); + this.state.thumbRX = updateAxis(event, MotionEvent.AXIS_Z, historyPos, this.state.thumbRX); + this.state.thumbRY = updateAxis(event, MotionEvent.AXIS_RZ, historyPos, this.state.thumbRY); + if (historyPos == -1 && !JoyConSupport.isJoyCon(event.getDevice())) { float axisX = getCenteredAxis(event, MotionEvent.AXIS_HAT_X, historyPos); float axisY = getCenteredAxis(event, MotionEvent.AXIS_HAT_Y, historyPos); GamepadState gamepadState = this.state; @@ -202,6 +202,12 @@ private void processJoystickInput(MotionEvent event, int historyPos) { } } + private static float updateAxis(MotionEvent event, int axis, int historyPos, float retained) { + InputDevice device = event.getDevice(); + boolean reported = device != null && device.getMotionRange(axis, event.getSource()) != null; + return JoyConSupport.axisValue(reported, retained, getCenteredAxis(event, axis, historyPos)); + } + private void processTriggerButton(MotionEvent event) { float l = event.getAxisValue(event.getAxisValue(MotionEvent.AXIS_LTRIGGER) == 0.0f ? MotionEvent.AXIS_BRAKE : MotionEvent.AXIS_LTRIGGER); float r = event.getAxisValue(event.getAxisValue(MotionEvent.AXIS_RTRIGGER) == 0.0f ? MotionEvent.AXIS_GAS : MotionEvent.AXIS_RTRIGGER); @@ -249,7 +255,7 @@ private void processXboxTriggerButton(MotionEvent event) { public boolean updateStateFromMotionEvent(MotionEvent event) { if (isJoystickDevice(event)) { - if (triggerType == TRIGGER_IS_AXIS) + if (triggerType == TRIGGER_IS_AXIS && !JoyConSupport.isJoyCon(event.getDevice())) processTriggerButton(event); else if (triggerType == TRIGGER_IS_BUTTON && isXboxController()) processXboxTriggerButton(event); @@ -264,17 +270,17 @@ else if (triggerType == TRIGGER_IS_BUTTON && isXboxController()) public boolean updateStateFromKeyEvent(KeyEvent event) { boolean z = false; boolean pressed = event.getAction() == KeyEvent.ACTION_DOWN; - int keyCode = event.getKeyCode(); + int keyCode = JoyConSupport.remapKeyCode(event.getDevice(), event); int buttonIdx = getButtonIdxByKeyCode(keyCode); if (buttonIdx != -1) { if (buttonIdx == IDX_BUTTON_L2) { - if (triggerType == TRIGGER_IS_BUTTON) { + if (triggerType == TRIGGER_IS_BUTTON || JoyConSupport.isJoyCon(event.getDevice())) { state.triggerL = pressed ? 1.0f : 0f; state.setPressed(buttonIdx, pressed); } else return true; } else if (buttonIdx == IDX_BUTTON_R2) { - if (triggerType == TRIGGER_IS_BUTTON) { + if (triggerType == TRIGGER_IS_BUTTON || JoyConSupport.isJoyCon(event.getDevice())) { state.triggerR = pressed ? 1.0f : 0f; state.setPressed(buttonIdx, pressed); } else diff --git a/app/src/main/java/com/winlator/inputcontrols/JoyConSupport.java b/app/src/main/java/com/winlator/inputcontrols/JoyConSupport.java new file mode 100644 index 0000000000..3d20f8fbc0 --- /dev/null +++ b/app/src/main/java/com/winlator/inputcontrols/JoyConSupport.java @@ -0,0 +1,82 @@ +package com.winlator.inputcontrols; + +import android.view.InputDevice; +import android.view.KeyEvent; + +import java.util.Collection; + +/** Compatibility helpers for Nintendo Switch Joy-Con halves. */ +public final class JoyConSupport { + public static final int NINTENDO_VENDOR_ID = 0x057e; + public static final int JOY_CON_LEFT_PRODUCT_ID = 0x2006; + public static final int JOY_CON_RIGHT_PRODUCT_ID = 0x2007; + public static final String PAIRED_IDENTIFIER = "nintendo_joycon_pair"; + + private JoyConSupport() {} + + public static boolean isJoyCon(InputDevice device) { + return device != null && isJoyCon(device.getVendorId(), device.getProductId()); + } + + static boolean isJoyCon(int vendorId, int productId) { + return vendorId == NINTENDO_VENDOR_ID + && (productId == JOY_CON_LEFT_PRODUCT_ID || productId == JOY_CON_RIGHT_PRODUCT_ID); + } + + /** Returns true only when the connected topology contains one left and one right Joy-Con. */ + public static boolean hasExactlyOnePair(Collection connectedDevices) { + int leftCount = 0; + int rightCount = 0; + for (int[] ids : connectedDevices) { + if (ids == null || ids.length < 2 || ids[0] != NINTENDO_VENDOR_ID) continue; + if (ids[1] == JOY_CON_LEFT_PRODUCT_ID) leftCount++; + if (ids[1] == JOY_CON_RIGHT_PRODUCT_ID) rightCount++; + } + return leftCount == 1 && rightCount == 1; + } + + public static int remapKeyCode(InputDevice device, KeyEvent event) { + if (device == null || event == null) { + return event != null ? event.getKeyCode() : KeyEvent.KEYCODE_UNKNOWN; + } + return remapKeyCode( + device.getVendorId(), device.getProductId(), event.getScanCode(), event.getKeyCode()); + } + + static int remapKeyCode(int vendorId, int productId, int scanCode, int fallbackKeyCode) { + if (vendorId != NINTENDO_VENDOR_ID) return fallbackKeyCode; + if (productId == JOY_CON_LEFT_PRODUCT_ID) { + switch (scanCode) { + case 544: return KeyEvent.KEYCODE_DPAD_UP; + case 545: return KeyEvent.KEYCODE_DPAD_DOWN; + case 546: return KeyEvent.KEYCODE_DPAD_LEFT; + case 547: return KeyEvent.KEYCODE_DPAD_RIGHT; + case 309: return KeyEvent.KEYCODE_BUTTON_MODE; + case 310: return KeyEvent.KEYCODE_BUTTON_L1; + case 312: return KeyEvent.KEYCODE_BUTTON_L2; + case 314: return KeyEvent.KEYCODE_BUTTON_SELECT; + case 317: return KeyEvent.KEYCODE_BUTTON_THUMBL; + default: return fallbackKeyCode; + } + } + if (productId == JOY_CON_RIGHT_PRODUCT_ID) { + switch (scanCode) { + case 304: return KeyEvent.KEYCODE_BUTTON_A; + case 305: return KeyEvent.KEYCODE_BUTTON_B; + case 307: return KeyEvent.KEYCODE_BUTTON_Y; + case 308: return KeyEvent.KEYCODE_BUTTON_X; + case 311: return KeyEvent.KEYCODE_BUTTON_R1; + case 313: return KeyEvent.KEYCODE_BUTTON_R2; + case 315: return KeyEvent.KEYCODE_BUTTON_START; + case 316: return KeyEvent.KEYCODE_BUTTON_MODE; + case 318: return KeyEvent.KEYCODE_BUTTON_THUMBR; + default: return fallbackKeyCode; + } + } + return fallbackKeyCode; + } + + static float axisValue(boolean reported, float retained, float current) { + return reported ? current : retained; + } +} diff --git a/app/src/main/java/com/winlator/winhandler/WinHandler.java b/app/src/main/java/com/winlator/winhandler/WinHandler.java index 6b4a330303..bc3b7a009a 100644 --- a/app/src/main/java/com/winlator/winhandler/WinHandler.java +++ b/app/src/main/java/com/winlator/winhandler/WinHandler.java @@ -18,6 +18,7 @@ import com.winlator.inputcontrols.ControlsProfile; import com.winlator.inputcontrols.ExternalController; import com.winlator.inputcontrols.GamepadState; +import com.winlator.inputcontrols.JoyConSupport; import com.winlator.inputcontrols.TouchMouse; import com.winlator.math.XForm; import com.winlator.widget.InputControlsView; @@ -237,6 +238,17 @@ private ExternalController getControllerFromSlot(int slot){ return extraControllers[slot -1]; } + private boolean isEventFromController(ExternalController controller, int eventDeviceId) { + if (controller == null) return false; + if (controller.getDeviceId() == eventDeviceId) return true; + InputDevice eventDevice = InputDevice.getDevice(eventDeviceId); + InputDevice controllerDevice = InputDevice.getDevice(controller.getDeviceId()); + return JoyConSupport.isJoyCon(eventDevice) + && JoyConSupport.isJoyCon(controllerDevice) + && JoyConSupport.PAIRED_IDENTIFIER.equals( + ControllerManager.getDeviceIdentifier(eventDevice)); + } + private MappedByteBuffer getGamepadBuffer(int slot) { if (slot == 0) return gamepadBuffer; if (slot < 0 || slot >= MAX_PLAYERS) return null; @@ -958,14 +970,14 @@ public boolean onGenericMotionEvent(MotionEvent event) { int slot = controllerManager.getSlotForDevice(event.getDeviceId()); if (slot >= 0) { ExternalController controller = getControllerFromSlot(slot); - if (controller == null || controller.getDeviceId() != event.getDeviceId()) { + if (!isEventFromController(controller, event.getDeviceId())) { Log.d(TAG, "Motion event refresh for deviceId=" + event.getDeviceId() + " slot=" + slot + " controller=" + (controller != null ? controller.getDeviceId() : -1)); refreshControllerMappings(); controller = getControllerFromSlot(slot); } - if (controller != null && controller.getDeviceId() == event.getDeviceId()) { + if (isEventFromController(controller, event.getDeviceId())) { handled = controller.updateStateFromMotionEvent(event); if (handled) { sendMemoryFileState(controller, getGamepadBuffer(slot), slot); @@ -1017,14 +1029,14 @@ public boolean onKeyEvent(KeyEvent event) { if (slot >= 0) { ExternalController controller = getControllerFromSlot(slot); - if (controller == null || controller.getDeviceId() != event.getDeviceId()) { + if (!isEventFromController(controller, event.getDeviceId())) { Log.d(TAG, "Key event refresh for deviceId=" + event.getDeviceId() + " slot=" + slot + " controller=" + (controller != null ? controller.getDeviceId() : -1)); refreshControllerMappings(); controller = getControllerFromSlot(slot); } - if (controller != null && controller.getDeviceId() == event.getDeviceId()) { + if (isEventFromController(controller, event.getDeviceId())) { if (event.getRepeatCount() > 0) return true; handled = controller.updateStateFromKeyEvent(event); // or motion variant Log.d(TAG, "Key routed deviceId=" + event.getDeviceId() diff --git a/app/src/test/java/com/winlator/inputcontrols/JoyConSupportTest.kt b/app/src/test/java/com/winlator/inputcontrols/JoyConSupportTest.kt new file mode 100644 index 0000000000..ca67063d09 --- /dev/null +++ b/app/src/test/java/com/winlator/inputcontrols/JoyConSupportTest.kt @@ -0,0 +1,87 @@ +package com.winlator.inputcontrols + +import android.view.KeyEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class JoyConSupportTest { + @Test + fun `shares identity only for exactly one left and one right Joy-Con`() { + assertTrue(JoyConSupport.hasExactlyOnePair(listOf( + ids(JoyConSupport.NINTENDO_VENDOR_ID, JoyConSupport.JOY_CON_LEFT_PRODUCT_ID), + ids(JoyConSupport.NINTENDO_VENDOR_ID, JoyConSupport.JOY_CON_RIGHT_PRODUCT_ID), + ))) + assertFalse(JoyConSupport.hasExactlyOnePair(listOf( + ids(JoyConSupport.NINTENDO_VENDOR_ID, JoyConSupport.JOY_CON_LEFT_PRODUCT_ID), + ))) + assertFalse(JoyConSupport.hasExactlyOnePair(listOf( + ids(JoyConSupport.NINTENDO_VENDOR_ID, JoyConSupport.JOY_CON_LEFT_PRODUCT_ID), + ids(JoyConSupport.NINTENDO_VENDOR_ID, JoyConSupport.JOY_CON_LEFT_PRODUCT_ID), + ids(JoyConSupport.NINTENDO_VENDOR_ID, JoyConSupport.JOY_CON_RIGHT_PRODUCT_ID), + ))) + assertFalse(JoyConSupport.hasExactlyOnePair(listOf( + ids(JoyConSupport.NINTENDO_VENDOR_ID, JoyConSupport.JOY_CON_LEFT_PRODUCT_ID), + ids(JoyConSupport.NINTENDO_VENDOR_ID, JoyConSupport.JOY_CON_RIGHT_PRODUCT_ID), + ids(JoyConSupport.NINTENDO_VENDOR_ID, JoyConSupport.JOY_CON_RIGHT_PRODUCT_ID), + ))) + } + + @Test + fun `maps Joy-Con Linux scan codes and passes other keys through`() { + mapOf( + 544 to KeyEvent.KEYCODE_DPAD_UP, + 545 to KeyEvent.KEYCODE_DPAD_DOWN, + 546 to KeyEvent.KEYCODE_DPAD_LEFT, + 547 to KeyEvent.KEYCODE_DPAD_RIGHT, + 309 to KeyEvent.KEYCODE_BUTTON_MODE, + 310 to KeyEvent.KEYCODE_BUTTON_L1, + 312 to KeyEvent.KEYCODE_BUTTON_L2, + 314 to KeyEvent.KEYCODE_BUTTON_SELECT, + 317 to KeyEvent.KEYCODE_BUTTON_THUMBL, + ).forEach { (scanCode, expected) -> + assertEquals(expected, JoyConSupport.remapKeyCode( + JoyConSupport.NINTENDO_VENDOR_ID, + JoyConSupport.JOY_CON_LEFT_PRODUCT_ID, + scanCode, + KeyEvent.KEYCODE_UNKNOWN, + )) + } + + mapOf( + 304 to KeyEvent.KEYCODE_BUTTON_A, + 305 to KeyEvent.KEYCODE_BUTTON_B, + 307 to KeyEvent.KEYCODE_BUTTON_Y, + 308 to KeyEvent.KEYCODE_BUTTON_X, + 311 to KeyEvent.KEYCODE_BUTTON_R1, + 313 to KeyEvent.KEYCODE_BUTTON_R2, + 315 to KeyEvent.KEYCODE_BUTTON_START, + 316 to KeyEvent.KEYCODE_BUTTON_MODE, + 318 to KeyEvent.KEYCODE_BUTTON_THUMBR, + ).forEach { (scanCode, expected) -> + assertEquals(expected, JoyConSupport.remapKeyCode( + JoyConSupport.NINTENDO_VENDOR_ID, + JoyConSupport.JOY_CON_RIGHT_PRODUCT_ID, + scanCode, + KeyEvent.KEYCODE_UNKNOWN, + )) + } + + assertEquals(KeyEvent.KEYCODE_BUTTON_X, JoyConSupport.remapKeyCode( + 0x045e, 0x0b13, 308, KeyEvent.KEYCODE_BUTTON_X, + )) + assertEquals(KeyEvent.KEYCODE_BUTTON_Y, JoyConSupport.remapKeyCode( + JoyConSupport.NINTENDO_VENDOR_ID, JoyConSupport.JOY_CON_RIGHT_PRODUCT_ID, + 999, KeyEvent.KEYCODE_BUTTON_Y, + )) + } + + @Test + fun `missing axes preserve retained values`() { + assertEquals(0.65f, JoyConSupport.axisValue(false, 0.65f, 0f), 0f) + assertEquals(-0.4f, JoyConSupport.axisValue(true, 0.65f, -0.4f), 0f) + } + + private fun ids(vendorId: Int, productId: Int) = intArrayOf(vendorId, productId) +}