From c9deed41328d0733e991ae0b609ec3fa72da57c2 Mon Sep 17 00:00:00 2001 From: eve-ai-dev Date: Mon, 31 Aug 2026 10:22:54 +0000 Subject: [PATCH 1/3] fix(input): pair complementary Joy-Con controllers Treat one unambiguous Nintendo Joy-Con L/R pair as a single logical controller while preserving normal controllers. Keep slot ownership stable across reconnects and add focused regression coverage for detection, remapping, pairing, merged state, and assignment cleanup. --- .../xserver/PhysicalControllerHandler.kt | 24 +- .../ui/screen/xserver/XServerScreen.kt | 6 +- .../inputcontrols/ControllerManager.java | 233 +++++++++++++++++- .../inputcontrols/ExternalController.java | 7 +- .../winlator/inputcontrols/GamepadState.java | 25 ++ .../winlator/inputcontrols/JoyConSupport.java | 148 +++++++++++ .../com/winlator/winhandler/WinHandler.java | 97 ++++++-- .../inputcontrols/JoyConSupportTest.kt | 157 ++++++++++++ 8 files changed, 648 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/com/winlator/inputcontrols/JoyConSupport.java create mode 100644 app/src/test/java/com/winlator/inputcontrols/JoyConSupportTest.kt 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/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index 35f75efa84..d30162880d 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -1600,7 +1600,8 @@ fun XServerScreen( winHandler.refreshControllerMappingsForHotplug() } val assignedSlot = ControllerManager.getInstance().getSlotForDevice(it.event.device.id) - if (assignedSlot > 0) { + val pairedJoyCon = ControllerManager.getInstance().isPairedJoyCon(it.event.device.id) + if (assignedSlot > 0 || pairedJoyCon) { handled = winHandler.onKeyEvent(it.event) } else { winHandler.setCurrentController(it.event.device.id) @@ -1652,7 +1653,8 @@ fun XServerScreen( val winHandler = xServerView!!.getxServer().winHandler ControllerManager.getInstance().noteGamepadActivity(it.event) val assignedSlot = ControllerManager.getInstance().getSlotForDevice(it.event.device.id) - if (assignedSlot > 0) { + val pairedJoyCon = ControllerManager.getInstance().isPairedJoyCon(it.event.device.id) + if (assignedSlot > 0 || pairedJoyCon) { handled = winHandler.onGenericMotionEvent(it.event) } else { winHandler.setCurrentController(it.event.device.id) diff --git a/app/src/main/java/com/winlator/inputcontrols/ControllerManager.java b/app/src/main/java/com/winlator/inputcontrols/ControllerManager.java index 5716191de1..2acb5c5b5a 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ControllerManager.java +++ b/app/src/main/java/com/winlator/inputcontrols/ControllerManager.java @@ -52,11 +52,14 @@ private ControllerManager() { // This list will hold all physical game controllers detected by Android. private final List detectedDevices = new ArrayList<>(); private final SparseArray knownDeviceIdentifiers = new SparseArray<>(); + private final Map knownVendorIdsByIdentifier = new HashMap<>(); + private final Map knownProductIdsByIdentifier = new HashMap<>(); // This maps a player slot (0-3) to the unique identifier of the physical device. // e.g., key=0, value="vendor_123_product_456" private final SparseArray slotAssignments = new SparseArray<>(); private final Map lastKnownSlotByIdentifier = new HashMap<>(); + private final Map pairedJoyConSlotByIdentifier = new HashMap<>(); // This tracks which of the 4 player slots are enabled by the user. private final boolean[] enabledSlots = new boolean[MAX_SLOTS]; @@ -76,6 +79,7 @@ public interface OnSlotsChangedListener { public static final String PREF_PLAYER_SLOT_PREFIX = "controller_slot_"; public static final String PREF_ENABLED_SLOTS_PREFIX = "enabled_slot_"; + private static final String PREF_JOY_CON_PAIR_MEMBERS_PREFIX = "joy_con_pair_members_"; /** @@ -110,7 +114,11 @@ public void scanForDevices() { detectedDevices.add(device); String ident = getDeviceIdentifier(device); knownDeviceIdentifiers.put(deviceId, ident); - if (ident != null) present.add(ident); + if (ident != null) { + present.add(ident); + knownVendorIdsByIdentifier.put(ident, device.getVendorId()); + knownProductIdsByIdentifier.put(ident, device.getProductId()); + } } } long now = SystemClock.elapsedRealtime(); @@ -138,6 +146,7 @@ private void scheduleSettleAssign() { private void loadAssignments() { slotAssignments.clear(); lastKnownSlotByIdentifier.clear(); + pairedJoyConSlotByIdentifier.clear(); for (int i = 0; i < MAX_SLOTS; i++) { // Load which device is assigned to this slot String prefKey = PREF_PLAYER_SLOT_PREFIX + i; @@ -146,6 +155,11 @@ private void loadAssignments() { slotAssignments.put(i, deviceIdentifier); lastKnownSlotByIdentifier.put(deviceIdentifier, i); } + for (String pairMember : preferences.getStringSet( + PREF_JOY_CON_PAIR_MEMBERS_PREFIX + i, java.util.Collections.emptySet())) { + pairedJoyConSlotByIdentifier.put(pairMember, i); + lastKnownSlotByIdentifier.put(pairMember, i); + } // Load whether this slot is enabled. Default P1=true, P2-4=false. String enabledKey = PREF_ENABLED_SLOTS_PREFIX + i; @@ -168,6 +182,17 @@ public void saveAssignments() { editor.remove(prefKey); } + Set pairMembers = new HashSet<>(); + for (Map.Entry entry : pairedJoyConSlotByIdentifier.entrySet()) { + if (entry.getValue() == i) pairMembers.add(entry.getKey()); + } + String pairMembersKey = PREF_JOY_CON_PAIR_MEMBERS_PREFIX + i; + if (pairMembers.isEmpty()) { + editor.remove(pairMembersKey); + } else { + editor.putStringSet(pairMembersKey, pairMembers); + } + // Save the enabled state String enabledKey = PREF_ENABLED_SLOTS_PREFIX + i; editor.putBoolean(enabledKey, enabledSlots[i]); @@ -212,7 +237,8 @@ public static boolean isGameController(InputDevice device) { } } - return (isGamepad && hasGamepadKeys) || + return JoyConSupport.isJoyCon(device) || + (isGamepad && hasGamepadKeys) || (isJoystick && hasAxes); } @@ -293,6 +319,17 @@ private void assignDeviceIdentifierToSlot(int slotIndex, String newDeviceIdentif } } + // Moving a remembered Joy-Con member invalidates the pair's previous logical slot. + Integer previousPairSlot = pairedJoyConSlotByIdentifier.get(newDeviceIdentifier); + if (previousPairSlot != null && previousPairSlot != slotIndex) { + pairedJoyConSlotByIdentifier.entrySet().removeIf(entry -> entry.getValue().equals(previousPairSlot)); + } + + // A different physical controller taking this slot invalidates stale Joy-Con pair memory. + if (!Integer.valueOf(slotIndex).equals(pairedJoyConSlotByIdentifier.get(newDeviceIdentifier))) { + pairedJoyConSlotByIdentifier.entrySet().removeIf(entry -> entry.getValue() == slotIndex); + } + // Assign the new device to the target slot. slotAssignments.put(slotIndex, newDeviceIdentifier); lastKnownSlotByIdentifier.put(newDeviceIdentifier, slotIndex); @@ -310,6 +347,7 @@ public void unassignSlot(int slotIndex) { lastKnownSlotByIdentifier.put(deviceIdentifier, slotIndex); } slotAssignments.remove(slotIndex); + pairedJoyConSlotByIdentifier.entrySet().removeIf(entry -> entry.getValue() == slotIndex); markSlotRecentlyFreed(slotIndex); saveAssignments(); notifySlotsChanged(); @@ -321,10 +359,119 @@ public void unassignSlot(int slotIndex) { * @return The player slot index (0-3), or -1 if the device is not assigned. */ public int getSlotForDevice(int deviceId) { + InputDevice device = inputManager.getInputDevice(deviceId); String deviceIdentifier = getDeviceIdentifierForDeviceId(deviceId); if (deviceIdentifier == null) return -1; - return getSlotForIdentifier(deviceIdentifier); + int directSlot = getSlotForIdentifier(deviceIdentifier); + InputDevice complementaryJoyCon = findComplementaryJoyCon(device); + int complementaryDirectSlot = complementaryJoyCon == null + ? -1 + : getSlotForIdentifier(getDeviceIdentifier(complementaryJoyCon)); + return JoyConSupport.resolveLogicalSlot( + JoyConSupport.isJoyCon(device), + directSlot, + complementaryDirectSlot, + pairedJoyConSlotByIdentifier.getOrDefault(deviceIdentifier, -1), + getConnectedJoyConCount()); + } + + public boolean isPairedJoyCon(int deviceId) { + InputDevice device = inputManager.getInputDevice(deviceId); + InputDevice complementaryJoyCon = findComplementaryJoyCon(device); + if (complementaryJoyCon == null) return false; + return JoyConSupport.shouldFusePair( + getSlotForIdentifier(getDeviceIdentifier(device)), + getSlotForIdentifier(getDeviceIdentifier(complementaryJoyCon))); + } + + private InputDevice findComplementaryJoyCon(InputDevice device) { + if (!JoyConSupport.isJoyCon(device)) return null; + List joyConIds = new ArrayList<>(); + for (InputDevice candidate : detectedDevices) { + if (JoyConSupport.isJoyCon(candidate)) { + joyConIds.add(new int[]{candidate.getVendorId(), candidate.getProductId()}); + } + } + if (!JoyConSupport.isUnambiguousPair(joyConIds)) return null; + + InputDevice match = null; + for (InputDevice candidate : detectedDevices) { + if (candidate.getId() != device.getId() && JoyConSupport.areComplementary(device, candidate)) { + match = candidate; + } + } + return match; + } + + private int getConnectedJoyConCount() { + int count = 0; + for (InputDevice device : detectedDevices) { + if (JoyConSupport.isJoyCon(device)) count++; + } + return count; + } + + private boolean rememberJoyConPairSlot(InputDevice device, int slot) { + InputDevice complement = findComplementaryJoyCon(device); + if (slot < 0 || complement == null) return false; + String identifier = getDeviceIdentifier(device); + String complementIdentifier = getDeviceIdentifier(complement); + if (identifier == null || complementIdentifier == null) return false; + boolean changed = !Integer.valueOf(slot).equals(pairedJoyConSlotByIdentifier.get(identifier)) + || !Integer.valueOf(slot).equals(pairedJoyConSlotByIdentifier.get(complementIdentifier)); + pairedJoyConSlotByIdentifier.put(identifier, slot); + pairedJoyConSlotByIdentifier.put(complementIdentifier, slot); + lastKnownSlotByIdentifier.put(identifier, slot); + lastKnownSlotByIdentifier.put(complementIdentifier, slot); + return changed; + } + + /** + * Older versions assigned each half of a single Joy-Con set to a different player. Collapse + * that persisted layout to the lower player slot so existing users receive pairing without + * having to clear controller settings. Multiple sets remain untouched because they are + * ambiguous without an explicit pairing relationship from Android. + */ + private boolean collapseLegacyJoyConPairAssignments() { + List joyCons = new ArrayList<>(); + List joyConIds = new ArrayList<>(); + for (InputDevice device : detectedDevices) { + if (JoyConSupport.isJoyCon(device)) { + joyCons.add(device); + joyConIds.add(new int[]{device.getVendorId(), device.getProductId()}); + } + } + if (!JoyConSupport.isUnambiguousPair(joyConIds)) return false; + + InputDevice first = joyCons.get(0); + InputDevice second = joyCons.get(1); + String firstIdentifier = getDeviceIdentifier(first); + String secondIdentifier = getDeviceIdentifier(second); + int firstSlot = getSlotForIdentifier(firstIdentifier); + int secondSlot = getSlotForIdentifier(secondIdentifier); + int ownerSlot = JoyConSupport.getLegacyPairOwnerSlot(firstSlot, secondSlot); + if (ownerSlot < 0) return false; + int releasedSlot = Math.max(firstSlot, secondSlot); + String ownerIdentifier = firstSlot == ownerSlot ? firstIdentifier : secondIdentifier; + slotAssignments.put(ownerSlot, ownerIdentifier); + slotAssignments.remove(releasedSlot); + lastKnownSlotByIdentifier.put(firstIdentifier, ownerSlot); + lastKnownSlotByIdentifier.put(secondIdentifier, ownerSlot); + pairedJoyConSlotByIdentifier.put(firstIdentifier, ownerSlot); + pairedJoyConSlotByIdentifier.put(secondIdentifier, ownerSlot); + markSlotRecentlyFreed(releasedSlot); + Log.i(TAG, "Collapsed legacy split Joy-Con assignments into Player " + (ownerSlot + 1)); + return true; + } + + /** Returns every connected physical device contributing to a player slot. */ + public List getDevicesForSlot(int slotIndex) { + List devices = new ArrayList<>(); + for (InputDevice device : detectedDevices) { + if (getSlotForDevice(device.getId()) == slotIndex) devices.add(device); + } + return devices; } private int getSlotForIdentifier(String deviceIdentifier) { @@ -342,6 +489,12 @@ private int getSlotForIdentifier(String deviceIdentifier) { return -1; // Not found } + /** Returns the directly assigned identifier that owns this device's logical slot. */ + private String getSlotOwnerIdentifier(int deviceId) { + int slot = getSlotForDevice(deviceId); + return slot >= 0 ? slotAssignments.get(slot) : null; + } + private String getDeviceIdentifierForDeviceId(int deviceId) { InputDevice device = inputManager.getInputDevice(deviceId); String deviceIdentifier = getDeviceIdentifier(device); @@ -402,8 +555,15 @@ public void onDeviceConnected(int deviceId) { knownDeviceIdentifiers.put(deviceId, deviceIdentifier); scanForDevices(); + if (collapseLegacyJoyConPairAssignments()) { + saveAssignments(); + notifySlotsChanged(); + } int existing = getSlotForDevice(deviceId); if (existing >= 0) { + if (rememberJoyConPairSlot(device, existing)) { + saveAssignments(); + } return; } @@ -431,10 +591,15 @@ public void onDeviceConnected(int deviceId) { */ public void autoAssignConnectedDevices() { scanForDevices(); - boolean changed = false; + boolean changed = collapseLegacyJoyConPairAssignments(); for (InputDevice device : detectedDevices) { String deviceIdentifier = getDeviceIdentifier(device); - if (deviceIdentifier == null || getSlotForDevice(device.getId()) >= 0) { + int logicalSlot = getSlotForDevice(device.getId()); + if (deviceIdentifier == null) { + continue; + } + if (logicalSlot >= 0) { + changed |= rememberJoyConPairSlot(device, logicalSlot); continue; } @@ -466,17 +631,54 @@ public void autoAssignConnectedDevices() { public void onDeviceDisconnected(int deviceId) { String deviceIdentifier = getDeviceIdentifierForDeviceId(deviceId); int slot = getSlotForIdentifier(deviceIdentifier); + Integer disconnectedVendorId = knownVendorIdsByIdentifier.get(deviceIdentifier); + Integer disconnectedProductId = knownProductIdsByIdentifier.get(deviceIdentifier); + String replacementIdentifier = null; + int complementaryCount = 0; + if (slot >= 0 && disconnectedVendorId != null && disconnectedProductId != null) { + for (InputDevice device : detectedDevices) { + if (device.getId() != deviceId && JoyConSupport.areComplementary( + disconnectedVendorId, + disconnectedProductId, + device.getVendorId(), + device.getProductId())) { + complementaryCount++; + String candidateIdentifier = getDeviceIdentifier(device); + if (getSlotForIdentifier(candidateIdentifier) < 0) { + replacementIdentifier = candidateIdentifier; + } + } + } + if (complementaryCount != 1) replacementIdentifier = null; + } knownDeviceIdentifiers.remove(deviceId); scanForDevices(); if (slot >= 0) { + InputDevice replacement = null; + if (replacementIdentifier != null) { + for (InputDevice device : detectedDevices) { + if (replacementIdentifier.equals(getDeviceIdentifier(device))) { + replacement = device; + break; + } + } + } slotAssignments.remove(slot); if (deviceIdentifier != null) { lastKnownSlotByIdentifier.put(deviceIdentifier, slot); } - markSlotRecentlyFreed(slot); + if (replacement != null) { + assignDeviceIdentifierToSlot(slot, getDeviceIdentifier(replacement)); + Log.i(TAG, "Promoted remaining Joy-Con deviceId=" + replacement.getId() + + " to Player " + (slot + 1)); + } else { + markSlotRecentlyFreed(slot); + } saveAssignments(); notifySlotsChanged(); - Log.i(TAG, "Unassigned disconnected deviceId=" + deviceId + " from Player " + (slot + 1)); + if (replacement == null) { + Log.i(TAG, "Unassigned disconnected deviceId=" + deviceId + " from Player " + (slot + 1)); + } } } @@ -511,7 +713,9 @@ public boolean noteGamepadButton(int deviceId) { if (slot == 0) return false; InputDevice occupant = getAssignedDeviceForSlot(0); if (occupant == null) { - String deviceIdentifier = getDeviceIdentifierForDeviceId(deviceId); + String deviceIdentifier = JoyConSupport.resolveClaimOwnerIdentifier( + getSlotOwnerIdentifier(deviceId), + getDeviceIdentifierForDeviceId(deviceId)); if (deviceIdentifier == null) return false; assignDeviceIdentifierToSlot(0, deviceIdentifier); saveAssignments(); @@ -521,7 +725,7 @@ public boolean noteGamepadButton(int deviceId) { } String occupantIdentifier = getDeviceIdentifier(occupant); if (occupantIdentifier == null || sessionActiveIdentifiers.contains(occupantIdentifier)) return false; - String deviceIdentifier = getDeviceIdentifierForDeviceId(deviceId); + String deviceIdentifier = getSlotOwnerIdentifier(deviceId); if (deviceIdentifier == null) return false; assignDeviceIdentifierToSlot(0, deviceIdentifier); if (slot > 0) { @@ -534,7 +738,10 @@ public boolean noteGamepadButton(int deviceId) { } private void markActive(int deviceId) { - String identifier = getDeviceIdentifierForDeviceId(deviceId); + String identifier = getSlotOwnerIdentifier(deviceId); + if (identifier == null) { + identifier = getDeviceIdentifierForDeviceId(deviceId); + } if (identifier == null || !sessionActiveIdentifiers.add(identifier)) return; InputDevice device = inputManager.getInputDevice(deviceId); if (device != null && @@ -555,7 +762,11 @@ private static boolean isRealGamepadMotion(MotionEvent event) { } private boolean isSlotAvailable(int slot) { - return slot >= 0 && slot < MAX_SLOTS && getAssignedDeviceForSlot(slot) == null; + if (slot < 0 || slot >= MAX_SLOTS) return false; + for (InputDevice device : detectedDevices) { + if (getSlotForDevice(device.getId()) == slot) return false; + } + return true; } private int getPreferredFreeSlot(String deviceIdentifier) { diff --git a/app/src/main/java/com/winlator/inputcontrols/ExternalController.java b/app/src/main/java/com/winlator/inputcontrols/ExternalController.java index fef4af5d9a..1570c8b5a5 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ExternalController.java +++ b/app/src/main/java/com/winlator/inputcontrols/ExternalController.java @@ -184,7 +184,7 @@ private void processJoystickInput(MotionEvent event, int 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) { + 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; @@ -264,7 +264,7 @@ 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) { @@ -391,7 +391,8 @@ public static boolean isGameController(InputDevice device) { } } - return (isGamepad && hasGamepadKeys) || + return JoyConSupport.isJoyCon(device) || + (isGamepad && hasGamepadKeys) || (isJoystick && hasAxes); } diff --git a/app/src/main/java/com/winlator/inputcontrols/GamepadState.java b/app/src/main/java/com/winlator/inputcontrols/GamepadState.java index 37234a98de..97eb4c5502 100644 --- a/app/src/main/java/com/winlator/inputcontrols/GamepadState.java +++ b/app/src/main/java/com/winlator/inputcontrols/GamepadState.java @@ -1,6 +1,7 @@ package com.winlator.inputcontrols; import java.nio.ByteBuffer; +import java.util.Collection; public class GamepadState { public float thumbLX = 0; @@ -66,4 +67,28 @@ public void copy(GamepadState other) { this.buttons = other.buttons; System.arraycopy(other.dpad, 0, this.dpad, 0, 4); } + + public void mergeFrom(GamepadState other) { + if (other == null) return; + thumbLX = strongest(thumbLX, other.thumbLX); + thumbLY = strongest(thumbLY, other.thumbLY); + thumbRX = strongest(thumbRX, other.thumbRX); + thumbRY = strongest(thumbRY, other.thumbRY); + triggerL = Math.max(triggerL, other.triggerL); + triggerR = Math.max(triggerR, other.triggerR); + buttons |= other.buttons; + for (int i = 0; i < dpad.length; i++) dpad[i] |= other.dpad[i]; + } + + public static GamepadState combine(Collection states) { + GamepadState combined = new GamepadState(); + if (states != null) { + for (GamepadState state : states) combined.mergeFrom(state); + } + return combined; + } + + private static float strongest(float current, float candidate) { + return Math.abs(candidate) > Math.abs(current) ? candidate : current; + } } 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..897eb9e678 --- /dev/null +++ b/app/src/main/java/com/winlator/inputcontrols/JoyConSupport.java @@ -0,0 +1,148 @@ +package com.winlator.inputcontrols; + +import android.view.InputDevice; +import android.view.KeyEvent; + +import java.util.Collection; + +/** Compatibility helpers for Nintendo Switch Joy-Con halves exposed as separate Android devices. */ +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; + + private JoyConSupport() {} + + public static boolean isLeftJoyCon(InputDevice device) { + return device != null && isLeftJoyCon(device.getVendorId(), device.getProductId()); + } + + public static boolean isRightJoyCon(InputDevice device) { + return device != null && isRightJoyCon(device.getVendorId(), device.getProductId()); + } + + public static boolean isJoyCon(InputDevice device) { + return device != null && isJoyCon(device.getVendorId(), device.getProductId()); + } + + public static boolean isLeftJoyCon(int vendorId, int productId) { + return vendorId == NINTENDO_VENDOR_ID && productId == JOY_CON_LEFT_PRODUCT_ID; + } + + public static boolean isRightJoyCon(int vendorId, int productId) { + return vendorId == NINTENDO_VENDOR_ID && productId == JOY_CON_RIGHT_PRODUCT_ID; + } + + public static boolean isJoyCon(int vendorId, int productId) { + return isLeftJoyCon(vendorId, productId) || isRightJoyCon(vendorId, productId); + } + + public static boolean areComplementary(InputDevice first, InputDevice second) { + return first != null && second != null && areComplementary( + first.getVendorId(), first.getProductId(), second.getVendorId(), second.getProductId()); + } + + public static boolean areComplementary( + int firstVendorId, + int firstProductId, + int secondVendorId, + int secondProductId + ) { + return (isLeftJoyCon(firstVendorId, firstProductId) && isRightJoyCon(secondVendorId, secondProductId)) + || (isRightJoyCon(firstVendorId, firstProductId) && isLeftJoyCon(secondVendorId, secondProductId)); + } + + /** + * Returns whether the supplied IDs describe exactly one left and one right Joy-Con. + * Multiple same-side candidates are intentionally not paired because Android exposes no + * stable relationship that lets us determine which physical set belongs together. + */ + public static boolean isUnambiguousPair(Collection vendorProductIds) { + if (vendorProductIds == null) return false; + int leftCount = 0; + int rightCount = 0; + for (int[] ids : vendorProductIds) { + if (ids == null || ids.length < 2) continue; + if (isLeftJoyCon(ids[0], ids[1])) leftCount++; + if (isRightJoyCon(ids[0], ids[1])) rightCount++; + } + return leftCount == 1 && rightCount == 1; + } + + /** A pair is fused only while exactly one of its halves owns a player slot. */ + public static boolean shouldFusePair(int firstDirectSlot, int secondDirectSlot) { + return (firstDirectSlot >= 0) != (secondDirectSlot >= 0); + } + + /** + * Resolves a Joy-Con's logical player slot without letting persisted pair metadata fuse an + * ambiguous multi-pair topology. A lone surviving half may reuse the pair's persisted slot. + */ + public static int resolveLogicalSlot( + boolean isJoyCon, + int directSlot, + int complementaryDirectSlot, + int persistedPairSlot, + int connectedJoyConCount + ) { + if (directSlot >= 0 || !isJoyCon) return directSlot; + if (complementaryDirectSlot >= 0) return complementaryDirectSlot; + return connectedJoyConCount == 1 ? persistedPairSlot : -1; + } + + /** Returns the slot to retain when migrating a legacy split assignment, or -1 if not needed. */ + public static int getLegacyPairOwnerSlot(int firstDirectSlot, int secondDirectSlot) { + return firstDirectSlot >= 0 && secondDirectSlot >= 0 && firstDirectSlot != secondDirectSlot + ? Math.min(firstDirectSlot, secondDirectSlot) + : -1; + } + + /** Prefers the logical pair owner when a physical controller claims another player slot. */ + public static String resolveClaimOwnerIdentifier(String logicalOwnerIdentifier, String directIdentifier) { + return logicalOwnerIdentifier != null ? logicalOwnerIdentifier : directIdentifier; + } + + /** + * Android reports several Joy-Con buttons as unknown or with a generic layout. Translate the + * Linux scan codes used by the Joy-Con key layouts into stable Android gamepad key codes. + */ + public static int remapKeyCode(int vendorId, int productId, int scanCode, int fallbackKeyCode) { + if (isLeftJoyCon(vendorId, productId)) { + 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 (isRightJoyCon(vendorId, productId)) { + 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; + } + + 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()); + } + +} diff --git a/app/src/main/java/com/winlator/winhandler/WinHandler.java b/app/src/main/java/com/winlator/winhandler/WinHandler.java index 6b4a330303..c251871279 100644 --- a/app/src/main/java/com/winlator/winhandler/WinHandler.java +++ b/app/src/main/java/com/winlator/winhandler/WinHandler.java @@ -7,6 +7,7 @@ import android.os.VibrationEffect; import android.os.Vibrator; import android.util.Log; +import android.util.SparseArray; import android.view.InputDevice; import android.view.KeyEvent; import android.view.MotionEvent; @@ -18,6 +19,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; @@ -57,6 +59,7 @@ public class WinHandler { public static final int MAX_PLAYERS = 4; private final MappedByteBuffer[] extraGamepadBuffers = new MappedByteBuffer[MAX_PLAYERS - 1]; private final ExternalController[] extraControllers = new ExternalController[MAX_PLAYERS - 1]; + private final SparseArray sourceControllers = new SparseArray<>(); private MappedByteBuffer gamepadBuffer; private static final short SERVER_PORT = 7947; private static final short CLIENT_PORT = 7946; @@ -181,7 +184,9 @@ public void refreshControllerMappingsForHotplug() { private void refreshControllerMappings(boolean clearDisconnectedSlots) { Log.d(TAG, "Refreshing controller assignments from settings..."); + SparseArray previousSourceControllers = sourceControllers.clone(); currentController = null; + sourceControllers.clear(); for (int i = 0; i < extraControllers.length; i++) { extraControllers[i] = null; } @@ -213,6 +218,25 @@ private void refreshControllerMappings(boolean clearDisconnectedSlots) { setGamepadSlotConnected(i + 1, extraControllers[i] != null); } + for (int slot = 0; slot < MAX_PLAYERS; slot++) { + for (InputDevice device : controllerManager.getDevicesForSlot(slot)) { + ExternalController sourceController = previousSourceControllers.get(device.getId()); + if (sourceController == null) { + sourceController = createSourceController(device.getId()); + } else { + configureSourceController(sourceController, device); + } + if (sourceController != null) { + sourceControllers.put(device.getId(), sourceController); + } + } + ExternalController outputController = getControllerFromSlot(slot); + if (outputController != null) { + updateCombinedSlotState(slot); + sendMemoryFileState(outputController, getGamepadBuffer(slot), slot); + } + } + if (clearDisconnectedSlots) { clearDisconnectedGamepadSlots(); sendGamepadState(); @@ -227,6 +251,10 @@ public void reassertPrimaryController() { if (c != null) { c.setContext(activity); currentController = c; + if (sourceControllers.get(p1Device.getId()) == null) { + ExternalController source = createSourceController(p1Device.getId()); + if (source != null) sourceControllers.put(p1Device.getId(), source); + } } } @@ -237,6 +265,43 @@ private ExternalController getControllerFromSlot(int slot){ return extraControllers[slot -1]; } + private ExternalController getSourceController(int deviceId) { + ExternalController controller = sourceControllers.get(deviceId); + if (controller == null) { + controller = createSourceController(deviceId); + if (controller != null) sourceControllers.put(deviceId, controller); + } + return controller; + } + + private ExternalController createSourceController(int deviceId) { + ExternalController controller = ExternalController.getController(deviceId); + if (controller == null) return null; + configureSourceController(controller, InputDevice.getDevice(deviceId)); + return controller; + } + + private void configureSourceController(ExternalController controller, InputDevice device) { + controller.setContext(activity); + if (JoyConSupport.isJoyCon(device)) { + controller.setTriggerType(ExternalController.TRIGGER_IS_BUTTON); + } + } + + private GamepadState getCombinedStateForSlot(int slot) { + List states = new ArrayList<>(); + for (InputDevice device : controllerManager.getDevicesForSlot(slot)) { + ExternalController sourceController = sourceControllers.get(device.getId()); + if (sourceController != null) states.add(sourceController.state); + } + return GamepadState.combine(states); + } + + private void updateCombinedSlotState(int slot) { + ExternalController outputController = getControllerFromSlot(slot); + if (outputController != null) outputController.state.copy(getCombinedStateForSlot(slot)); + } + private MappedByteBuffer getGamepadBuffer(int slot) { if (slot == 0) return gamepadBuffer; if (slot < 0 || slot >= MAX_PLAYERS) return null; @@ -957,18 +1022,12 @@ public boolean onGenericMotionEvent(MotionEvent event) { boolean handled = false; int slot = controllerManager.getSlotForDevice(event.getDeviceId()); if (slot >= 0) { - ExternalController controller = getControllerFromSlot(slot); - if (controller == null || controller.getDeviceId() != 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()) { + ExternalController controller = getSourceController(event.getDeviceId()); + if (controller != null) { handled = controller.updateStateFromMotionEvent(event); if (handled) { - sendMemoryFileState(controller, getGamepadBuffer(slot), slot); + updateCombinedSlotState(slot); + sendMemoryFileState(getControllerFromSlot(slot), getGamepadBuffer(slot), slot); sendGamepadState(); } return handled; @@ -1016,24 +1075,18 @@ public boolean onKeyEvent(KeyEvent event) { InputDevice device = event.getDevice(); if (slot >= 0) { - ExternalController controller = getControllerFromSlot(slot); - if (controller == null || controller.getDeviceId() != 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()) { + ExternalController controller = getSourceController(event.getDeviceId()); + if (controller != null) { if (event.getRepeatCount() > 0) return true; - handled = controller.updateStateFromKeyEvent(event); // or motion variant + handled = controller.updateStateFromKeyEvent(event); Log.d(TAG, "Key routed deviceId=" + event.getDeviceId() - + " keyCode=" + event.getKeyCode() + + " keyCode=" + JoyConSupport.remapKeyCode(event.getDevice(), event) + " action=" + event.getAction() + " -> P" + (slot + 1) + " handled=" + handled + " buffer=" + (getGamepadBuffer(slot) != null)); - sendMemoryFileState(controller, getGamepadBuffer(slot), slot); + updateCombinedSlotState(slot); + sendMemoryFileState(getControllerFromSlot(slot), getGamepadBuffer(slot), slot); if (handled) sendGamepadState(); return handled; } 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..6a0234b60e --- /dev/null +++ b/app/src/test/java/com/winlator/inputcontrols/JoyConSupportTest.kt @@ -0,0 +1,157 @@ +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 `combines retained state from both controller halves`() { + val left = GamepadState().apply { + thumbLX = -0.75f + dpad[0] = true + setPressed(ExternalController.IDX_BUTTON_L1.toInt(), true) + } + val right = GamepadState().apply { + thumbRX = 0.6f + triggerR = 1f + setPressed(ExternalController.IDX_BUTTON_A.toInt(), true) + } + + val combined = GamepadState.combine(listOf(left, right)) + + assertEquals(-0.75f, combined.thumbLX, 0f) + assertEquals(0.6f, combined.thumbRX, 0f) + assertEquals(1f, combined.triggerR, 0f) + assertTrue(combined.dpad[0]) + assertTrue(combined.isPressed(ExternalController.IDX_BUTTON_L1.toInt())) + assertTrue(combined.isPressed(ExternalController.IDX_BUTTON_A.toInt())) + } + + @Test + fun `combination uses the strongest axis contribution`() { + val first = GamepadState().apply { thumbLX = -0.8f } + val second = GamepadState().apply { thumbLX = 0.25f } + + assertEquals(-0.8f, GamepadState.combine(listOf(first, second)).thumbLX, 0f) + } + + @Test + fun `recombination drops released and disconnected source state`() { + val left = GamepadState().apply { + thumbLX = -0.8f + setPressed(ExternalController.IDX_BUTTON_L1.toInt(), true) + } + val right = GamepadState().apply { + setPressed(ExternalController.IDX_BUTTON_A.toInt(), true) + } + + left.thumbLX = 0f + left.setPressed(ExternalController.IDX_BUTTON_L1.toInt(), false) + val afterRelease = GamepadState.combine(listOf(left, right)) + assertEquals(0f, afterRelease.thumbLX, 0f) + assertFalse(afterRelease.isPressed(ExternalController.IDX_BUTTON_L1.toInt())) + assertTrue(afterRelease.isPressed(ExternalController.IDX_BUTTON_A.toInt())) + + val afterDisconnect = GamepadState.combine(emptyList()) + assertEquals(0f, afterDisconnect.thumbLX, 0f) + assertEquals(0, afterDisconnect.buttons.toInt()) + } + + @Test + fun `identifies complementary Nintendo Joy-Cons`() { + assertTrue(JoyConSupport.isJoyCon(JoyConSupport.NINTENDO_VENDOR_ID, JoyConSupport.JOY_CON_LEFT_PRODUCT_ID)) + assertTrue(JoyConSupport.isJoyCon(JoyConSupport.NINTENDO_VENDOR_ID, JoyConSupport.JOY_CON_RIGHT_PRODUCT_ID)) + assertTrue(JoyConSupport.areComplementary( + JoyConSupport.NINTENDO_VENDOR_ID, + JoyConSupport.JOY_CON_LEFT_PRODUCT_ID, + JoyConSupport.NINTENDO_VENDOR_ID, + JoyConSupport.JOY_CON_RIGHT_PRODUCT_ID, + )) + assertFalse(JoyConSupport.areComplementary( + JoyConSupport.NINTENDO_VENDOR_ID, + JoyConSupport.JOY_CON_LEFT_PRODUCT_ID, + JoyConSupport.NINTENDO_VENDOR_ID, + JoyConSupport.JOY_CON_LEFT_PRODUCT_ID, + )) + } + + @Test + fun `pairs only one unambiguous left and right set`() { + val left = intArrayOf(JoyConSupport.NINTENDO_VENDOR_ID, JoyConSupport.JOY_CON_LEFT_PRODUCT_ID) + val right = intArrayOf(JoyConSupport.NINTENDO_VENDOR_ID, JoyConSupport.JOY_CON_RIGHT_PRODUCT_ID) + + assertTrue(JoyConSupport.isUnambiguousPair(listOf(left, right))) + assertFalse(JoyConSupport.isUnambiguousPair(listOf(left, left, right))) + assertFalse(JoyConSupport.isUnambiguousPair(listOf(left, right, right))) + assertTrue(JoyConSupport.shouldFusePair(0, -1)) + assertTrue(JoyConSupport.shouldFusePair(-1, 2)) + assertFalse(JoyConSupport.shouldFusePair(0, 1)) + assertFalse(JoyConSupport.shouldFusePair(-1, -1)) + assertEquals(0, JoyConSupport.getLegacyPairOwnerSlot(0, 1)) + assertEquals(1, JoyConSupport.getLegacyPairOwnerSlot(3, 1)) + assertEquals(-1, JoyConSupport.getLegacyPairOwnerSlot(0, -1)) + assertEquals(-1, JoyConSupport.getLegacyPairOwnerSlot(2, 2)) + } + + @Test + fun `surviving half recovers persisted pair slot without fusing ambiguous topology`() { + assertEquals(2, JoyConSupport.resolveLogicalSlot(true, -1, -1, 2, 1)) + assertEquals(-1, JoyConSupport.resolveLogicalSlot(true, -1, -1, 2, 4)) + assertEquals(1, JoyConSupport.resolveLogicalSlot(true, -1, 1, 2, 2)) + assertEquals(-1, JoyConSupport.resolveLogicalSlot(false, -1, -1, 2, 1)) + } + + @Test + fun `non-owner half moves the logical pair owner when claiming player one`() { + assertEquals("left-owner", JoyConSupport.resolveClaimOwnerIdentifier("left-owner", "right-half")) + assertEquals("ordinary-controller", JoyConSupport.resolveClaimOwnerIdentifier(null, "ordinary-controller")) + } + + + @Test + fun `maps left Joy-Con Linux scan codes to Android controls`() { + assertEquals(KeyEvent.KEYCODE_DPAD_UP, JoyConSupport.remapKeyCode( + JoyConSupport.NINTENDO_VENDOR_ID, JoyConSupport.JOY_CON_LEFT_PRODUCT_ID, 544, KeyEvent.KEYCODE_UNKNOWN, + )) + assertEquals(KeyEvent.KEYCODE_DPAD_LEFT, JoyConSupport.remapKeyCode( + JoyConSupport.NINTENDO_VENDOR_ID, JoyConSupport.JOY_CON_LEFT_PRODUCT_ID, 546, KeyEvent.KEYCODE_UNKNOWN, + )) + assertEquals(KeyEvent.KEYCODE_BUTTON_L2, JoyConSupport.remapKeyCode( + JoyConSupport.NINTENDO_VENDOR_ID, JoyConSupport.JOY_CON_LEFT_PRODUCT_ID, 312, KeyEvent.KEYCODE_UNKNOWN, + )) + } + + @Test + fun `maps right Joy-Con by XInput position plus shoulders and system buttons`() { + val expectedMappings = mapOf( + 304 to KeyEvent.KEYCODE_BUTTON_A, // Nintendo B: south + 305 to KeyEvent.KEYCODE_BUTTON_B, // Nintendo A: east + 307 to KeyEvent.KEYCODE_BUTTON_Y, // Nintendo X: north + 308 to KeyEvent.KEYCODE_BUTTON_X, // Nintendo Y: west + 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, + ) + + expectedMappings.forEach { (scanCode, expectedKeyCode) -> + assertEquals(expectedKeyCode, JoyConSupport.remapKeyCode( + JoyConSupport.NINTENDO_VENDOR_ID, + JoyConSupport.JOY_CON_RIGHT_PRODUCT_ID, + scanCode, + KeyEvent.KEYCODE_UNKNOWN, + )) + } + } + + @Test + fun `preserves normal controller key codes`() { + assertEquals(KeyEvent.KEYCODE_BUTTON_X, JoyConSupport.remapKeyCode( + 0x045e, 0x0b13, 308, KeyEvent.KEYCODE_BUTTON_X, + )) + } +} From 0cce75151dbefa17645a3e9697b6909341d4fff5 Mon Sep 17 00:00:00 2001 From: eve-ai-dev Date: Mon, 31 Aug 2026 11:10:57 +0000 Subject: [PATCH 2/3] fix(input): harden paired Joy-Con lifecycle --- .../ui/screen/xserver/XServerScreen.kt | 12 ++-- .../inputcontrols/ControllerManager.java | 71 +++++++++++++++++-- .../winlator/inputcontrols/JoyConSupport.java | 42 +++++++++++ .../com/winlator/winhandler/WinHandler.java | 36 +++++----- .../inputcontrols/JoyConSupportTest.kt | 24 +++++++ 5 files changed, 156 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index d30162880d..d354d3967e 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -1599,9 +1599,9 @@ fun XServerScreen( ) { winHandler.refreshControllerMappingsForHotplug() } - val assignedSlot = ControllerManager.getInstance().getSlotForDevice(it.event.device.id) - val pairedJoyCon = ControllerManager.getInstance().isPairedJoyCon(it.event.device.id) - if (assignedSlot > 0 || pairedJoyCon) { + val routeDirectly = ControllerManager.getInstance() + .shouldRouteDirectlyToWinHandler(it.event.device.id) + if (routeDirectly) { handled = winHandler.onKeyEvent(it.event) } else { winHandler.setCurrentController(it.event.device.id) @@ -1652,9 +1652,9 @@ fun XServerScreen( if (isGamepad && it.event != null) { val winHandler = xServerView!!.getxServer().winHandler ControllerManager.getInstance().noteGamepadActivity(it.event) - val assignedSlot = ControllerManager.getInstance().getSlotForDevice(it.event.device.id) - val pairedJoyCon = ControllerManager.getInstance().isPairedJoyCon(it.event.device.id) - if (assignedSlot > 0 || pairedJoyCon) { + val routeDirectly = ControllerManager.getInstance() + .shouldRouteDirectlyToWinHandler(it.event.device.id) + if (routeDirectly) { handled = winHandler.onGenericMotionEvent(it.event) } else { winHandler.setCurrentController(it.event.device.id) diff --git a/app/src/main/java/com/winlator/inputcontrols/ControllerManager.java b/app/src/main/java/com/winlator/inputcontrols/ControllerManager.java index 2acb5c5b5a..612a94a582 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ControllerManager.java +++ b/app/src/main/java/com/winlator/inputcontrols/ControllerManager.java @@ -312,6 +312,12 @@ public void assignDeviceToSlot(int slotIndex, InputDevice device) { } private void assignDeviceIdentifierToSlot(int slotIndex, String newDeviceIdentifier) { + int previousDirectSlot = getSlotForIdentifier(newDeviceIdentifier); + Integer previousPairSlot = pairedJoyConSlotByIdentifier.get(newDeviceIdentifier); + boolean movesRememberedPair = previousPairSlot != null + && JoyConSupport.shouldMoveRememberedPair( + previousPairSlot, slotIndex, previousDirectSlot); + // First, remove the new device from any slot it might already be in. for (int i = 0; i < MAX_SLOTS; i++) { if (newDeviceIdentifier.equals(slotAssignments.get(i))) { @@ -319,10 +325,15 @@ private void assignDeviceIdentifierToSlot(int slotIndex, String newDeviceIdentif } } - // Moving a remembered Joy-Con member invalidates the pair's previous logical slot. - Integer previousPairSlot = pairedJoyConSlotByIdentifier.get(newDeviceIdentifier); + // Move pair memory with its direct owner; an isolated remembered member invalidates it. if (previousPairSlot != null && previousPairSlot != slotIndex) { - pairedJoyConSlotByIdentifier.entrySet().removeIf(entry -> entry.getValue().equals(previousPairSlot)); + if (movesRememberedPair) { + JoyConSupport.moveRememberedPairSlot( + pairedJoyConSlotByIdentifier, previousPairSlot, slotIndex); + } else { + pairedJoyConSlotByIdentifier.entrySet().removeIf( + entry -> entry.getValue().equals(previousPairSlot)); + } } // A different physical controller taking this slot invalidates stale Joy-Con pair memory. @@ -385,6 +396,26 @@ public boolean isPairedJoyCon(int deviceId) { getSlotForIdentifier(getDeviceIdentifier(complementaryJoyCon))); } + /** Returns whether this device bypasses the Player 1 profile path and goes to WinHandler. */ + public boolean shouldRouteDirectlyToWinHandler(int deviceId) { + InputDevice device = inputManager.getInputDevice(deviceId); + String identifier = getDeviceIdentifierForDeviceId(deviceId); + if (device == null || identifier == null) return false; + int directSlot = getSlotForIdentifier(identifier); + InputDevice complementaryJoyCon = findComplementaryJoyCon(device); + int complementaryDirectSlot = complementaryJoyCon == null + ? -1 + : getSlotForIdentifier(getDeviceIdentifier(complementaryJoyCon)); + int logicalSlot = JoyConSupport.resolveLogicalSlot( + JoyConSupport.isJoyCon(device), + directSlot, + complementaryDirectSlot, + pairedJoyConSlotByIdentifier.getOrDefault(identifier, -1), + getConnectedJoyConCount()); + return logicalSlot > 0 || (complementaryJoyCon != null + && JoyConSupport.shouldFusePair(directSlot, complementaryDirectSlot)); + } + private InputDevice findComplementaryJoyCon(InputDevice device) { if (!JoyConSupport.isJoyCon(device)) return null; List joyConIds = new ArrayList<>(); @@ -427,6 +458,33 @@ private boolean rememberJoyConPairSlot(InputDevice device, int slot) { return changed; } + private boolean promoteRememberedLoneHalfIfNeeded(InputDevice device, int logicalSlot) { + String identifier = getDeviceIdentifier(device); + int directSlot = getSlotForIdentifier(identifier); + String persistedOwner = slotAssignments.get(logicalSlot); + boolean persistedOwnerConnected = false; + if (persistedOwner != null) { + for (InputDevice candidate : detectedDevices) { + if (persistedOwner.equals(getDeviceIdentifier(candidate))) { + persistedOwnerConnected = true; + break; + } + } + } + if (!JoyConSupport.shouldPromoteRememberedLoneHalf( + JoyConSupport.isJoyCon(device), + directSlot, + pairedJoyConSlotByIdentifier.getOrDefault(identifier, -1), + getConnectedJoyConCount(), + persistedOwnerConnected)) { + return false; + } + assignDeviceIdentifierToSlot(logicalSlot, identifier); + Log.i(TAG, "Promoted remembered lone Joy-Con deviceId=" + device.getId() + + " to Player " + (logicalSlot + 1)); + return true; + } + /** * Older versions assigned each half of a single Joy-Con set to a different player. Collapse * that persisted layout to the lower player slot so existing users receive pairing without @@ -561,8 +619,10 @@ public void onDeviceConnected(int deviceId) { } int existing = getSlotForDevice(deviceId); if (existing >= 0) { - if (rememberJoyConPairSlot(device, existing)) { + if (promoteRememberedLoneHalfIfNeeded(device, existing) + | rememberJoyConPairSlot(device, existing)) { saveAssignments(); + notifySlotsChanged(); } return; } @@ -599,6 +659,7 @@ public void autoAssignConnectedDevices() { continue; } if (logicalSlot >= 0) { + changed |= promoteRememberedLoneHalfIfNeeded(device, logicalSlot); changed |= rememberJoyConPairSlot(device, logicalSlot); continue; } @@ -726,6 +787,8 @@ public boolean noteGamepadButton(int deviceId) { String occupantIdentifier = getDeviceIdentifier(occupant); if (occupantIdentifier == null || sessionActiveIdentifiers.contains(occupantIdentifier)) return false; String deviceIdentifier = getSlotOwnerIdentifier(deviceId); + deviceIdentifier = JoyConSupport.resolveClaimOwnerIdentifier( + deviceIdentifier, getDeviceIdentifierForDeviceId(deviceId)); if (deviceIdentifier == null) return false; assignDeviceIdentifierToSlot(0, deviceIdentifier); if (slot > 0) { diff --git a/app/src/main/java/com/winlator/inputcontrols/JoyConSupport.java b/app/src/main/java/com/winlator/inputcontrols/JoyConSupport.java index 897eb9e678..d06369a4b7 100644 --- a/app/src/main/java/com/winlator/inputcontrols/JoyConSupport.java +++ b/app/src/main/java/com/winlator/inputcontrols/JoyConSupport.java @@ -4,6 +4,7 @@ import android.view.KeyEvent; import java.util.Collection; +import java.util.Map; /** Compatibility helpers for Nintendo Switch Joy-Con halves exposed as separate Android devices. */ public final class JoyConSupport { @@ -102,6 +103,47 @@ public static String resolveClaimOwnerIdentifier(String logicalOwnerIdentifier, return logicalOwnerIdentifier != null ? logicalOwnerIdentifier : directIdentifier; } + /** A directly assigned pair owner carries both remembered members when it changes slots. */ + public static boolean shouldMoveRememberedPair( + int previousPairSlot, + int targetSlot, + int previousDirectSlot + ) { + return previousPairSlot >= 0 + && previousPairSlot != targetSlot + && previousDirectSlot == previousPairSlot; + } + + /** Moves all remembered members of a logical pair to the owner's new slot. */ + public static void moveRememberedPairSlot( + Map pairSlots, + int previousSlot, + int targetSlot + ) { + if (pairSlots == null || previousSlot == targetSlot) return; + pairSlots.replaceAll((identifier, slot) -> slot == previousSlot ? targetSlot : slot); + } + + /** A lone remembered non-owner must become the direct owner when the saved owner is absent. */ + public static boolean shouldPromoteRememberedLoneHalf( + boolean isJoyCon, + int directSlot, + int persistedPairSlot, + int connectedJoyConCount, + boolean persistedOwnerConnected + ) { + return isJoyCon + && directSlot < 0 + && persistedPairSlot >= 0 + && connectedJoyConCount == 1 + && !persistedOwnerConnected; + } + + /** Returns true when a controller with this descriptor may safely reuse cached source state. */ + public static boolean canReuseSourceController(String cachedDescriptor, String currentDescriptor) { + return cachedDescriptor != null && cachedDescriptor.equals(currentDescriptor); + } + /** * Android reports several Joy-Con buttons as unknown or with a generic layout. Translate the * Linux scan codes used by the Joy-Con key layouts into stable Android gamepad key codes. diff --git a/app/src/main/java/com/winlator/winhandler/WinHandler.java b/app/src/main/java/com/winlator/winhandler/WinHandler.java index c251871279..8c2fed9480 100644 --- a/app/src/main/java/com/winlator/winhandler/WinHandler.java +++ b/app/src/main/java/com/winlator/winhandler/WinHandler.java @@ -60,6 +60,7 @@ public class WinHandler { private final MappedByteBuffer[] extraGamepadBuffers = new MappedByteBuffer[MAX_PLAYERS - 1]; private final ExternalController[] extraControllers = new ExternalController[MAX_PLAYERS - 1]; private final SparseArray sourceControllers = new SparseArray<>(); + private final int[][] sourceDeviceIdsBySlot = new int[MAX_PLAYERS][]; private MappedByteBuffer gamepadBuffer; private static final short SERVER_PORT = 7947; private static final short CLIENT_PORT = 7946; @@ -219,9 +220,14 @@ private void refreshControllerMappings(boolean clearDisconnectedSlots) { } for (int slot = 0; slot < MAX_PLAYERS; slot++) { - for (InputDevice device : controllerManager.getDevicesForSlot(slot)) { + List slotDevices = controllerManager.getDevicesForSlot(slot); + sourceDeviceIdsBySlot[slot] = new int[slotDevices.size()]; + for (int index = 0; index < slotDevices.size(); index++) { + InputDevice device = slotDevices.get(index); + sourceDeviceIdsBySlot[slot][index] = device.getId(); ExternalController sourceController = previousSourceControllers.get(device.getId()); - if (sourceController == null) { + if (sourceController == null || !JoyConSupport.canReuseSourceController( + sourceController.getId(), device.getDescriptor())) { sourceController = createSourceController(device.getId()); } else { configureSourceController(sourceController, device); @@ -244,18 +250,8 @@ private void refreshControllerMappings(boolean clearDisconnectedSlots) { } public void reassertPrimaryController() { - controllerManager.scanForDevices(); - InputDevice p1Device = controllerManager.getAssignedDeviceForSlot(0); - if (p1Device == null) return; - ExternalController c = ExternalController.getController(p1Device.getId()); - if (c != null) { - c.setContext(activity); - currentController = c; - if (sourceControllers.get(p1Device.getId()) == null) { - ExternalController source = createSourceController(p1Device.getId()); - if (source != null) sourceControllers.put(p1Device.getId(), source); - } - } + refreshControllerMappings(false); + sendGamepadState(); } private ExternalController getControllerFromSlot(int slot){ @@ -283,15 +279,17 @@ private ExternalController createSourceController(int deviceId) { private void configureSourceController(ExternalController controller, InputDevice device) { controller.setContext(activity); - if (JoyConSupport.isJoyCon(device)) { - controller.setTriggerType(ExternalController.TRIGGER_IS_BUTTON); - } + controller.setTriggerType(JoyConSupport.isJoyCon(device) + ? ExternalController.TRIGGER_IS_BUTTON + : ExternalController.TRIGGER_IS_AXIS); } private GamepadState getCombinedStateForSlot(int slot) { List states = new ArrayList<>(); - for (InputDevice device : controllerManager.getDevicesForSlot(slot)) { - ExternalController sourceController = sourceControllers.get(device.getId()); + int[] deviceIds = sourceDeviceIdsBySlot[slot]; + if (deviceIds == null) return GamepadState.combine(states); + for (int deviceId : deviceIds) { + ExternalController sourceController = sourceControllers.get(deviceId); if (sourceController != null) states.add(sourceController.state); } return GamepadState.combine(states); diff --git a/app/src/test/java/com/winlator/inputcontrols/JoyConSupportTest.kt b/app/src/test/java/com/winlator/inputcontrols/JoyConSupportTest.kt index 6a0234b60e..68292371e7 100644 --- a/app/src/test/java/com/winlator/inputcontrols/JoyConSupportTest.kt +++ b/app/src/test/java/com/winlator/inputcontrols/JoyConSupportTest.kt @@ -110,6 +110,30 @@ class JoyConSupportTest { assertEquals("ordinary-controller", JoyConSupport.resolveClaimOwnerIdentifier(null, "ordinary-controller")) } + @Test + fun `moving direct pair owner preserves both remembered members`() { + val pairSlots = linkedMapOf("left" to 1, "right" to 1) + + assertTrue(JoyConSupport.shouldMoveRememberedPair(1, 0, 1)) + JoyConSupport.moveRememberedPairSlot(pairSlots, 1, 0) + + assertEquals(linkedMapOf("left" to 0, "right" to 0), pairSlots) + assertFalse(JoyConSupport.shouldMoveRememberedPair(1, 0, -1)) + } + + @Test + fun `remembered lone non-owner becomes direct slot owner`() { + assertTrue(JoyConSupport.shouldPromoteRememberedLoneHalf(true, -1, 2, 1, false)) + assertFalse(JoyConSupport.shouldPromoteRememberedLoneHalf(true, -1, 2, 2, false)) + assertFalse(JoyConSupport.shouldPromoteRememberedLoneHalf(true, -1, 2, 1, true)) + } + + @Test + fun `cached source state requires matching descriptor`() { + assertTrue(JoyConSupport.canReuseSourceController("descriptor-a", "descriptor-a")) + assertFalse(JoyConSupport.canReuseSourceController("descriptor-a", "descriptor-b")) + assertFalse(JoyConSupport.canReuseSourceController(null, "descriptor-a")) + } @Test fun `maps left Joy-Con Linux scan codes to Android controls`() { From e0a3350b00324854e8c23787b7c325c13240650d Mon Sep 17 00:00:00 2001 From: eve-ai-dev Date: Mon, 31 Aug 2026 11:26:42 +0000 Subject: [PATCH 3/3] fix(input): preserve slot occupants during claims --- .../inputcontrols/ControllerManager.java | 7 ++++--- .../winlator/inputcontrols/JoyConSupport.java | 8 +++++++- .../winlator/inputcontrols/JoyConSupportTest.kt | 16 ++++++++++++++-- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/winlator/inputcontrols/ControllerManager.java b/app/src/main/java/com/winlator/inputcontrols/ControllerManager.java index 612a94a582..c2448bf6b4 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ControllerManager.java +++ b/app/src/main/java/com/winlator/inputcontrols/ControllerManager.java @@ -790,10 +790,11 @@ public boolean noteGamepadButton(int deviceId) { deviceIdentifier = JoyConSupport.resolveClaimOwnerIdentifier( deviceIdentifier, getDeviceIdentifierForDeviceId(deviceId)); if (deviceIdentifier == null) return false; + int occupantDestinationSlot = JoyConSupport.resolveDisplacedOccupantSlot( + slot, slot < 0 ? getPreferredFreeSlot(occupantIdentifier) : -1); + if (occupantDestinationSlot < 0) return false; assignDeviceIdentifierToSlot(0, deviceIdentifier); - if (slot > 0) { - assignDeviceIdentifierToSlot(slot, occupantIdentifier); - } + assignDeviceIdentifierToSlot(occupantDestinationSlot, occupantIdentifier); saveAssignments(); notifySlotsChanged(); Log.i(TAG, "deviceId=" + deviceId + " displaced idle Player 1"); diff --git a/app/src/main/java/com/winlator/inputcontrols/JoyConSupport.java b/app/src/main/java/com/winlator/inputcontrols/JoyConSupport.java index d06369a4b7..77c5bb1c16 100644 --- a/app/src/main/java/com/winlator/inputcontrols/JoyConSupport.java +++ b/app/src/main/java/com/winlator/inputcontrols/JoyConSupport.java @@ -114,16 +114,22 @@ public static boolean shouldMoveRememberedPair( && previousDirectSlot == previousPairSlot; } - /** Moves all remembered members of a logical pair to the owner's new slot. */ + /** Replaces target-slot pair memory with all members of the pair moving from the old slot. */ public static void moveRememberedPairSlot( Map pairSlots, int previousSlot, int targetSlot ) { if (pairSlots == null || previousSlot == targetSlot) return; + pairSlots.entrySet().removeIf(entry -> entry.getValue() == targetSlot); pairSlots.replaceAll((identifier, slot) -> slot == previousSlot ? targetSlot : slot); } + /** Returns the slot that should retain an idle Player 1 occupant during a controller claim. */ + public static int resolveDisplacedOccupantSlot(int claimantSlot, int availableSlot) { + return claimantSlot > 0 ? claimantSlot : availableSlot; + } + /** A lone remembered non-owner must become the direct owner when the saved owner is absent. */ public static boolean shouldPromoteRememberedLoneHalf( boolean isJoyCon, diff --git a/app/src/test/java/com/winlator/inputcontrols/JoyConSupportTest.kt b/app/src/test/java/com/winlator/inputcontrols/JoyConSupportTest.kt index 68292371e7..c8480fa3b8 100644 --- a/app/src/test/java/com/winlator/inputcontrols/JoyConSupportTest.kt +++ b/app/src/test/java/com/winlator/inputcontrols/JoyConSupportTest.kt @@ -112,15 +112,27 @@ class JoyConSupportTest { @Test fun `moving direct pair owner preserves both remembered members`() { - val pairSlots = linkedMapOf("left" to 1, "right" to 1) + val pairSlots = linkedMapOf( + "moving-left" to 1, + "moving-right" to 1, + "displaced-left" to 0, + "displaced-right" to 0, + ) assertTrue(JoyConSupport.shouldMoveRememberedPair(1, 0, 1)) JoyConSupport.moveRememberedPairSlot(pairSlots, 1, 0) - assertEquals(linkedMapOf("left" to 0, "right" to 0), pairSlots) + assertEquals(linkedMapOf("moving-left" to 0, "moving-right" to 0), pairSlots) assertFalse(JoyConSupport.shouldMoveRememberedPair(1, 0, -1)) } + @Test + fun `unassigned claimant only displaces player one into a free slot`() { + assertEquals(2, JoyConSupport.resolveDisplacedOccupantSlot(-1, 2)) + assertEquals(-1, JoyConSupport.resolveDisplacedOccupantSlot(-1, -1)) + assertEquals(3, JoyConSupport.resolveDisplacedOccupantSlot(3, -1)) + } + @Test fun `remembered lone non-owner becomes direct slot owner`() { assertTrue(JoyConSupport.shouldPromoteRememberedLoneHalf(true, -1, 2, 1, false))