diff --git a/CHANGELOG.md b/CHANGELOG.md index b79928f4..52b7d855 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ computer. Those lines say "update Satellite too". - The Xbox/Guide button on XInput-style wired USB controllers (Xbox 360 and its many licensed clones, plus the Amazon Luna Controller) now works. Before this fix, pressing it did nothing. +- Plugging in a controller that is already connected over Bluetooth no + longer leaves the cable doing nothing. The controller card now shows + "USB available" with a "Use wired" button that walks you through + switching to the cable; Dish never switches on its own, so charging + while you keep playing over Bluetooth works exactly as before. --- diff --git a/app/src/main/java/com/tinkernorth/dish/ui/main/ControllerAdapter.kt b/app/src/main/java/com/tinkernorth/dish/ui/main/ControllerAdapter.kt index 814f0f00..83cd2aea 100644 --- a/app/src/main/java/com/tinkernorth/dish/ui/main/ControllerAdapter.kt +++ b/app/src/main/java/com/tinkernorth/dish/ui/main/ControllerAdapter.kt @@ -46,6 +46,8 @@ interface SlotActionListener { fun onSwitchToDirect(slotId: String) + fun onSetupWired(slotId: String) + fun onManageDestinations() fun onReconnect(slotId: String) @@ -264,6 +266,9 @@ class ControllerAdapter( val specs = mutableListOf(PillSpec(ctx.getString(label), icon, PillTone.FACT)) // The Direct/Standard mode chip only applies once a USB controller is on a known path. if (isUsb && kind != null) specs.add(usbModeSpec(card)) + if (isBt && card?.wiredSwitchAvailable == true) { + specs.add(PillSpec(ctx.getString(R.string.binding_usb_available), R.drawable.ic_usb, PillTone.WARN)) + } return specs } @@ -511,7 +516,9 @@ class ControllerAdapter( val slot = row.slot val bound = slot.boundStatus if (bound == null || slot.boundConnectionId == null) { - return listOf( + val unboundActions = mutableListOf() + if (row.pathCard?.wiredSwitchAvailable == true) unboundActions += setupWiredAction() + unboundActions += if (row.connections.isEmpty()) { CardAction( R.drawable.ic_satellite, @@ -526,8 +533,8 @@ class ControllerAdapter( outlined = true, kind = ActionKind.CONFIGURE, ) - }, - ) + } + return unboundActions } val actions = mutableListOf() val connected = bound.live == LinkState.Connected @@ -561,6 +568,7 @@ class ControllerAdapter( kind = ActionKind.SWITCH_DIRECT, ) } + if (row.pathCard?.wiredSwitchAvailable == true) actions += setupWiredAction() actions += CardAction( R.drawable.ic_tune, @@ -571,6 +579,14 @@ class ControllerAdapter( return actions } + private fun setupWiredAction(): CardAction = + CardAction( + R.drawable.ic_usb, + ctx.getString(R.string.binding_action_use_wired), + outlined = false, + kind = ActionKind.SETUP_WIRED, + ) + private fun dispatch( kind: ActionKind, slotId: String, @@ -579,6 +595,7 @@ class ControllerAdapter( ActionKind.GAMEPAD -> listener.onOpenGamepad(slotId) ActionKind.TOUCHPAD -> listener.onOpenTouchpad(slotId) ActionKind.SWITCH_DIRECT -> listener.onSwitchToDirect(slotId) + ActionKind.SETUP_WIRED -> listener.onSetupWired(slotId) ActionKind.CONFIGURE -> listener.onConfigure(slotId) ActionKind.FIND_HOSTS -> listener.onManageDestinations() } @@ -685,7 +702,7 @@ class ControllerAdapter( val kind: ActionKind, ) - private enum class ActionKind { GAMEPAD, TOUCHPAD, SWITCH_DIRECT, CONFIGURE, FIND_HOSTS } + private enum class ActionKind { GAMEPAD, TOUCHPAD, SWITCH_DIRECT, SETUP_WIRED, CONFIGURE, FIND_HOSTS } private enum class EdgeState { NONE, HOST_LOST, INPUT_LOST, UNSTEADY } diff --git a/app/src/main/java/com/tinkernorth/dish/ui/main/MainActivity.kt b/app/src/main/java/com/tinkernorth/dish/ui/main/MainActivity.kt index 7592b358..01a99990 100644 --- a/app/src/main/java/com/tinkernorth/dish/ui/main/MainActivity.kt +++ b/app/src/main/java/com/tinkernorth/dish/ui/main/MainActivity.kt @@ -301,6 +301,10 @@ class MainActivity : viewModel.setInputPath(slotId, PathChoice.Direct) } + override fun onSetupWired(slotId: String) { + nav.toSetupUsb() + } + override fun onOpenGamepad(slotId: String) { val state = viewModel.uiState.value val slot = state.slots.firstOrNull { it.id == slotId } ?: return diff --git a/app/src/main/java/com/tinkernorth/dish/ui/main/MainViewModel.kt b/app/src/main/java/com/tinkernorth/dish/ui/main/MainViewModel.kt index 924b4f82..62f00766 100644 --- a/app/src/main/java/com/tinkernorth/dish/ui/main/MainViewModel.kt +++ b/app/src/main/java/com/tinkernorth/dish/ui/main/MainViewModel.kt @@ -23,6 +23,7 @@ import com.tinkernorth.dish.source.store.MotionEnabledStore import com.tinkernorth.dish.source.store.TouchpadModeStore import com.tinkernorth.dish.source.store.UsbPathPreferenceStore import com.tinkernorth.dish.source.usb.PathChoice +import com.tinkernorth.dish.source.usb.UsbController import com.tinkernorth.dish.source.usb.UsbGamepadManager import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -115,11 +116,12 @@ class MainViewModel slotsBase, pathPrefs.state, inputRateStore.state, - ) { base, _, rates -> + usbGamepadManager.controllers, + ) { base, _, rates, usbControllers -> val pathCards = base.slots .mapNotNull { slot -> - pathCardFor(slot, base.devices)?.let { slot.id to it } + pathCardFor(slot, base.devices, usbControllers)?.let { slot.id to it } }.toMap() val inputRates = base.slots @@ -210,6 +212,7 @@ class MainViewModel private fun pathCardFor( slot: ControllerSlot, devices: Map, + usbControllers: Map, ): PathCard? { if (slot.inputType != SlotInputType.PHYSICAL) return null val device = devices[slot.physicalDeviceId] ?: return null @@ -243,6 +246,7 @@ class MainViewModel restoreStuck = device.restoreStuck, directFailure = device.directFailure, padHasTouchpad = native.modelHasTouchpad(vid, pid), + wiredUsbPresent = wiredUsbPresentFor(device, usbControllers.values), ) } diff --git a/app/src/main/java/com/tinkernorth/dish/ui/main/PathCard.kt b/app/src/main/java/com/tinkernorth/dish/ui/main/PathCard.kt index adf1ef38..d48a0d5c 100644 --- a/app/src/main/java/com/tinkernorth/dish/ui/main/PathCard.kt +++ b/app/src/main/java/com/tinkernorth/dish/ui/main/PathCard.kt @@ -39,6 +39,7 @@ data class PathCard( // Why the last Direct claim failed, when not already covered by needsReplug/restoreStuck. val failure: DirectClaimFailure? = null, val suggestDirectForTouch: Boolean = false, + val wiredSwitchAvailable: Boolean = false, ) object PathCardMapper { @@ -55,6 +56,7 @@ object PathCardMapper { restoreStuck: Boolean = false, directFailure: DirectClaimFailure? = null, padHasTouchpad: Boolean = false, + wiredUsbPresent: Boolean = false, ): PathCard { // The card reflects the mode the controller is ACTUALLY in: Direct only when a synthetic is live // (claimed, not mid-release, not stuck). Badge and toggle both derive from this so they can never @@ -91,6 +93,7 @@ object PathCardMapper { restoreStuck = restoreStuck, failure = directFailure, suggestDirectForTouch = suggestDirectForTouch, + wiredSwitchAvailable = wiredUsbPresent && transport == Transport.Bluetooth, ) } } diff --git a/app/src/main/java/com/tinkernorth/dish/ui/main/WiredUsbPresence.kt b/app/src/main/java/com/tinkernorth/dish/ui/main/WiredUsbPresence.kt new file mode 100644 index 00000000..b80e00d9 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/ui/main/WiredUsbPresence.kt @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +package com.tinkernorth.dish.ui.main + +import com.tinkernorth.dish.hotpath.input.PhysicalGamepadRegistry +import com.tinkernorth.dish.hotpath.input.Transport +import com.tinkernorth.dish.source.usb.UsbController +import com.tinkernorth.dish.source.usb.UsbPhase + +internal fun wiredUsbPresentFor( + device: PhysicalGamepadRegistry.Device, + usbControllers: Collection, +): Boolean { + if (device.transport != Transport.Bluetooth || device.isUsbSynthetic) return false + return usbControllers.any { unrepresentedUsbTwinOf(device, it) } +} + +private fun unrepresentedUsbTwinOf( + device: PhysicalGamepadRegistry.Device, + controller: UsbController, +): Boolean { + if (controller.phase != UsbPhase.Routed || !controller.usbPresent || controller.syntheticId != null) return false + if (controller.vendorId != device.vendorId) return false + return controller.frameworkId == null || controller.frameworkId == device.id +} diff --git a/app/src/main/res/values-bs/strings.xml b/app/src/main/res/values-bs/strings.xml index 92de96fe..dce088b6 100644 --- a/app/src/main/res/values-bs/strings.xml +++ b/app/src/main/res/values-bs/strings.xml @@ -344,6 +344,7 @@ Na ekranu Brzi Standardni + USB dostupan Vibracija Pokret Dodirna ploča @@ -360,6 +361,7 @@ Nijedna Podesi povezivanje Pronađi hostove + Koristi kabl Podesi povezivanje Poveži kontroler Primijeni diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 45336eb6..f7905eb8 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -341,6 +341,7 @@ Bildschirm Direkt Standard + USB verfügbar Vibration Bewegung Touchpad @@ -357,6 +358,7 @@ Keine Zuordnung konfigurieren Hosts finden + Kabel verwenden Zuordnung konfigurieren Controller zuordnen Übernehmen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5db31de9..0d5f0f01 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -345,6 +345,7 @@ En pantalla Directo Estándar + USB disponible Vibración Movimiento Panel táctil @@ -361,6 +362,7 @@ Ninguna Configurar vínculos Buscar hosts + Usar cable Configurar vínculos Vincular el mando Aplicar diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 58dfce9a..0be1120a 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -344,6 +344,7 @@ À l\'écran Direct Standard + USB disponible Vibration Mouvement Pavé tactile @@ -360,6 +361,7 @@ Aucune Configurer la liaison Trouver des hôtes + Utiliser le câble Configurer la liaison Lier la manette Appliquer diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 790d0eef..ee2c8ace 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -345,6 +345,7 @@ Na tela Direto Padrão + USB disponível Vibração Movimento Touchpad @@ -361,6 +362,7 @@ Nenhuma Configurar vínculos Encontrar hosts + Usar cabo Configurar vínculos Vincular o controle Aplicar diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 30da1081..0d01759b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -489,6 +489,7 @@ On-screen Direct Standard + USB available Rumble Motion @@ -508,6 +509,7 @@ Configure bindings Find hosts + Use wired Configure bindings Bind controller diff --git a/app/src/test/java/com/tinkernorth/dish/source/usb/UsbDualPresenceGhostTest.kt b/app/src/test/java/com/tinkernorth/dish/source/usb/UsbDualPresenceGhostTest.kt new file mode 100644 index 00000000..079a042d --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/source/usb/UsbDualPresenceGhostTest.kt @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +package com.tinkernorth.dish.source.usb + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class UsbDualPresenceGhostTest { + private fun lunaOnPlugIn( + frameworkId: Int? = null, + hasPermission: Boolean = false, + desired: PathChoice = PathChoice.Standard, + ) = UsbController( + vendorId = 0x1949, + productId = 0x041A, + name = "Amazon Luna Controller", + phase = UsbPhase.Routed, + usbPresent = true, + frameworkId = frameworkId, + hasPermission = hasPermission, + desired = desired, + ) + + @Test + fun `plugging in while bluetooth holds input parks the pad as a silent routed ghost`() { + val r = + reduce( + lunaOnPlugIn(frameworkId = null, hasPermission = false), + UsbEvent.Choose(PathChoice.Direct, userInitiated = false), + ) + assertEquals(UsbPhase.Routed, r.next?.phase) + assertEquals(PathChoice.Direct, r.next?.desired) + assertNull(r.next?.frameworkId) + assertNull(r.next?.syntheticId) + assertTrue(r.effects.isEmpty()) + } + + @Test + fun `the ghost never asks for permission on its own`() { + val r = + reduce( + lunaOnPlugIn(frameworkId = null, hasPermission = false), + UsbEvent.Choose(PathChoice.Direct, userInitiated = false), + ) + assertTrue(UsbEffect.RequestPermission !in r.effects) + assertTrue(UsbEffect.Claim !in r.effects) + } + + @Test + fun `an unverified ghost parks on standard with nothing to wait for`() { + val r = + reduce( + lunaOnPlugIn(frameworkId = null, hasPermission = false), + UsbEvent.Choose(PathChoice.Standard, userInitiated = false), + ) + assertEquals(UsbPhase.Routed, r.next?.phase) + assertEquals(PathChoice.Standard, r.next?.desired) + assertNull(r.next?.frameworkId) + assertTrue(r.effects.isEmpty()) + } + + @Test + fun `a same-id bluetooth twin reads as framework presence and still claims nothing`() { + val r = reduce(lunaOnPlugIn(frameworkId = null, desired = PathChoice.Direct), UsbEvent.FrameworkUp(42)) + assertEquals(UsbPhase.Routed, r.next?.phase) + assertEquals(42, r.next?.frameworkId) + assertTrue(r.effects.isEmpty()) + } + + @Test + fun `the ghost holds its direct intent without ever acting on it`() { + var c = lunaOnPlugIn(frameworkId = null, hasPermission = false) + c = reduce(c, UsbEvent.Choose(PathChoice.Direct, userInitiated = false)).next!! + val again = reduce(c, UsbEvent.Choose(PathChoice.Direct, userInitiated = false)) + assertEquals(UsbPhase.Routed, again.next?.phase) + assertEquals(PathChoice.Direct, again.next?.desired) + assertTrue(again.effects.isEmpty()) + } + + @Test + fun `only a user-initiated pick makes the ghost request permission`() { + val r = + reduce( + lunaOnPlugIn(frameworkId = null, hasPermission = false, desired = PathChoice.Direct), + UsbEvent.Choose(PathChoice.Direct, userInitiated = true), + ) + assertEquals(UsbPhase.Routed, r.next?.phase) + assertEquals(listOf(UsbEffect.RequestPermission), r.effects) + } + + @Test + fun `granting permission is what finally claims the ghost`() { + val r = + reduce( + lunaOnPlugIn(frameworkId = null, hasPermission = false, desired = PathChoice.Direct), + UsbEvent.PermissionGranted, + ) + assertEquals(UsbPhase.Claiming, r.next?.phase) + assertEquals(listOf(UsbEffect.ClearFailure, UsbEffect.BeginHold, UsbEffect.Claim), r.effects) + } + + @Test + fun `a user pick with prior permission claims without a prompt`() { + val r = + reduce( + lunaOnPlugIn(frameworkId = null, hasPermission = true), + UsbEvent.Choose(PathChoice.Direct, userInitiated = true), + ) + assertEquals(UsbPhase.Claiming, r.next?.phase) + assertEquals(listOf(UsbEffect.ClearFailure, UsbEffect.BeginHold, UsbEffect.Claim), r.effects) + } + + @Test + fun `unplugging the ghost forgets it without side effects`() { + val r = reduce(lunaOnPlugIn(frameworkId = null, desired = PathChoice.Direct), UsbEvent.UsbUnplugged) + assertNull(r.next) + assertTrue(r.effects.isEmpty()) + } +} diff --git a/app/src/test/java/com/tinkernorth/dish/ui/main/MainViewModelTest.kt b/app/src/test/java/com/tinkernorth/dish/ui/main/MainViewModelTest.kt index bdaf3674..f9629f83 100644 --- a/app/src/test/java/com/tinkernorth/dish/ui/main/MainViewModelTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/ui/main/MainViewModelTest.kt @@ -23,7 +23,9 @@ import com.tinkernorth.dish.source.store.MotionEnabledStore import com.tinkernorth.dish.source.store.TouchpadModeStore import com.tinkernorth.dish.source.store.UsbPathPreferenceStore import com.tinkernorth.dish.source.usb.PathChoice +import com.tinkernorth.dish.source.usb.UsbController import com.tinkernorth.dish.source.usb.UsbGamepadManager +import com.tinkernorth.dish.source.usb.UsbPhase import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -64,6 +66,7 @@ class MainViewModelTest { private val connectionsFlow = MutableStateFlow>(emptyList()) private val bindingsFlow = MutableStateFlow>(emptyMap()) private val devicesFlow = MutableStateFlow>(emptyMap()) + private val usbControllersFlow = MutableStateFlow>(emptyMap()) private val capabilityStateFlow = MutableStateFlow>(emptyMap()) private val satelliteEvents = MutableSharedFlow(extraBufferCapacity = 8) @@ -90,6 +93,7 @@ class MainViewModelTest { native = mockk(relaxed = true) pathPrefs = mockk(relaxed = true) usbGamepadManager = mockk(relaxed = true) + every { usbGamepadManager.controllers } returns usbControllersFlow every { hub.connections } returns connectionsFlow every { hub.bindings } returns bindingsFlow every { gamepadRegistry.devices } returns devicesFlow @@ -514,6 +518,41 @@ class MainViewModelTest { assertEquals(false, card?.suggestDirectForTouch) } + @Test + fun `a bluetooth pad with a plugged usb twin surfaces the wired switch on its card`() = + runTest(dispatcher) { + devicesFlow.value = mapOf(90 to routed(90, 0x1949, 0x0419, transport = Transport.Bluetooth)) + usbControllersFlow.value = + mapOf( + ((0x1949 shl 16) or 0x041A) to + UsbController( + vendorId = 0x1949, + productId = 0x041A, + name = "Amazon Luna Controller", + phase = UsbPhase.Routed, + ), + ) + dispatcher.scheduler.runCurrent() + + assertTrue( + vm.uiState.value.pathCards["90"] + ?.wiredSwitchAvailable == true, + ) + } + + @Test + fun `a bluetooth pad with no tracked usb twin gets no wired switch`() = + runTest(dispatcher) { + devicesFlow.value = mapOf(91 to routed(91, 0x1949, 0x0419, transport = Transport.Bluetooth)) + dispatcher.scheduler.runCurrent() + + assertEquals( + false, + vm.uiState.value.pathCards["91"] + ?.wiredSwitchAvailable, + ) + } + @Test fun `the virtual slot has no path card`() = runTest(dispatcher) { diff --git a/app/src/test/java/com/tinkernorth/dish/ui/main/PathCardMapperTest.kt b/app/src/test/java/com/tinkernorth/dish/ui/main/PathCardMapperTest.kt index d068539b..79bc32fc 100644 --- a/app/src/test/java/com/tinkernorth/dish/ui/main/PathCardMapperTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/ui/main/PathCardMapperTest.kt @@ -24,6 +24,7 @@ class PathCardMapperTest { restoreStuck: Boolean = false, directFailure: DirectClaimFailure? = null, padHasTouchpad: Boolean = false, + wiredUsbPresent: Boolean = false, ) = PathCardMapper.map( isClaimedDirect = isClaimedDirect, transport = transport, @@ -36,6 +37,7 @@ class PathCardMapperTest { restoreStuck = restoreStuck, directFailure = directFailure, padHasTouchpad = padHasTouchpad, + wiredUsbPresent = wiredUsbPresent, ) @Test @@ -157,4 +159,37 @@ class PathCardMapperTest { fun `the direct suggestion defaults off`() { assertFalse(map().suggestDirectForTouch) } + + @Test + fun `a bluetooth card with a plugged usb twin offers the wired switch`() { + assertTrue(map(transport = Transport.Bluetooth, wiredUsbPresent = true).wiredSwitchAvailable) + } + + @Test + fun `the wired switch never appears on a usb card`() { + assertFalse(map(transport = Transport.Usb, wiredUsbPresent = true).wiredSwitchAvailable) + } + + @Test + fun `a bluetooth card without the cable offers no wired switch`() { + assertFalse(map(transport = Transport.Bluetooth).wiredSwitchAvailable) + } + + @Test + fun `the wired switch defaults off`() { + assertFalse(map().wiredSwitchAvailable) + } + + @Test + fun `the wired switch leaves the direct toggle hidden on bluetooth`() { + val card = map(transport = Transport.Bluetooth, wiredUsbPresent = true) + assertFalse(card.directAvailable) + assertEquals(PathChoice.Standard, card.selected) + assertEquals(InputPathMode.Standard, card.currentMode) + } + + @Test + fun `the wired switch does not wake the touch suggestion on bluetooth`() { + assertFalse(map(transport = Transport.Bluetooth, wiredUsbPresent = true, padHasTouchpad = true).suggestDirectForTouch) + } } diff --git a/app/src/test/java/com/tinkernorth/dish/ui/main/WiredUsbPresenceTest.kt b/app/src/test/java/com/tinkernorth/dish/ui/main/WiredUsbPresenceTest.kt new file mode 100644 index 00000000..af610190 --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/ui/main/WiredUsbPresenceTest.kt @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +package com.tinkernorth.dish.ui.main + +import com.tinkernorth.dish.hotpath.input.PhysicalGamepadRegistry +import com.tinkernorth.dish.hotpath.input.Transport +import com.tinkernorth.dish.source.usb.UsbController +import com.tinkernorth.dish.source.usb.UsbPhase +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class WiredUsbPresenceTest { + private fun device( + id: Int = 5, + vendorId: Int = 0x1949, + productId: Int = 0x0419, + transport: Transport = Transport.Bluetooth, + isUsbSynthetic: Boolean = false, + ) = PhysicalGamepadRegistry.Device( + id = id, + name = "Amazon Game Controller", + vendorId = vendorId, + productId = productId, + transport = transport, + isUsbSynthetic = isUsbSynthetic, + ) + + private fun tracked( + vendorId: Int = 0x1949, + productId: Int = 0x041A, + phase: UsbPhase = UsbPhase.Routed, + usbPresent: Boolean = true, + frameworkId: Int? = null, + syntheticId: Int? = null, + ) = UsbController( + vendorId = vendorId, + productId = productId, + name = "Amazon Luna Controller", + phase = phase, + usbPresent = usbPresent, + frameworkId = frameworkId, + syntheticId = syntheticId, + ) + + @Test + fun `a luna-shaped orphan lights the wired switch on the same vendor's bluetooth card`() { + assertTrue( + wiredUsbPresentFor(device(vendorId = 0x1949, productId = 0x0419), listOf(tracked(vendorId = 0x1949, productId = 0x041A))), + ) + } + + @Test + fun `a same-id twin whose framework points at this card lights the switch`() { + val ds4 = device(id = 5, vendorId = 0x054C, productId = 0x05C4) + val twin = tracked(vendorId = 0x054C, productId = 0x05C4, frameworkId = 5) + assertTrue(wiredUsbPresentFor(ds4, listOf(twin))) + } + + @Test + fun `a twin claimed by a different card does not light this one`() { + val ds4 = device(id = 5, vendorId = 0x054C, productId = 0x05C4) + val twin = tracked(vendorId = 0x054C, productId = 0x05C4, frameworkId = 9) + assertFalse(wiredUsbPresentFor(ds4, listOf(twin))) + } + + @Test + fun `a different vendor's orphan never lights the switch`() { + assertFalse(wiredUsbPresentFor(device(vendorId = 0x054C), listOf(tracked(vendorId = 0x1949)))) + } + + @Test + fun `a usb card never lights the switch`() { + assertFalse(wiredUsbPresentFor(device(transport = Transport.Usb), listOf(tracked()))) + } + + @Test + fun `a synthetic card never lights the switch`() { + assertFalse(wiredUsbPresentFor(device(transport = Transport.Usb, isUsbSynthetic = true), listOf(tracked()))) + } + + @Test + fun `no tracked usb controllers means no switch`() { + assertFalse(wiredUsbPresentFor(device(), emptyList())) + } + + @Test + fun `a claim in flight does not light the switch`() { + assertFalse(wiredUsbPresentFor(device(), listOf(tracked(phase = UsbPhase.Claiming)))) + } + + @Test + fun `a pad already claimed direct does not light the switch`() { + assertFalse(wiredUsbPresentFor(device(), listOf(tracked(phase = UsbPhase.Direct, syntheticId = 77)))) + } + + @Test + fun `transient path states do not light the switch`() { + assertFalse(wiredUsbPresentFor(device(), listOf(tracked(phase = UsbPhase.AwaitingFramework)))) + assertFalse(wiredUsbPresentFor(device(), listOf(tracked(phase = UsbPhase.RestoreStuck)))) + assertFalse(wiredUsbPresentFor(device(), listOf(tracked(phase = UsbPhase.NeedsReplug)))) + } + + @Test + fun `an entry whose cable left does not light the switch`() { + assertFalse(wiredUsbPresentFor(device(), listOf(tracked(usbPresent = false)))) + } + + @Test + fun `a routed entry still holding a synthetic does not light the switch`() { + assertFalse(wiredUsbPresentFor(device(), listOf(tracked(syntheticId = 77)))) + } + + @Test + fun `one qualifying orphan among disqualified entries is enough`() { + val controllers = + listOf( + tracked(vendorId = 0x054C, productId = 0x05C4), + tracked(phase = UsbPhase.Claiming), + tracked(usbPresent = false), + tracked(), + ) + assertTrue(wiredUsbPresentFor(device(), controllers)) + } +}