Reported from the field: a Steam Controller 2 wired or on its Puck dongle changes the console UI to the controller layout, but the same pad paired over Bluetooth "doesn't come up as a controller at all, doesn't change the layout to controller layout like the other two modes do".
Root cause
The manifest declared BLUETOOTH_CONNECT and all three BLE gates tested for it — MainActivity.startSc2MenuNav, StreamScreen, and the Controllers screen — but no code path in the client ever requested it. Only mic, local-network and nearby-devices had permission launchers. It is a runtime permission from API 31, so every check answered DENIED forever and the BLE transport has been unreachable in every shipped build.
The failure was silent by construction: pairedControllers() wrapped bondedDevices in runCatching{}.getOrDefault(emptyList()), so the SecurityException from the missing permission read as "no controller is paired" — no log, no UI, nothing to grep. That is why this needed a field report rather than showing up in a log.
Second half of the same bug: the manifest carried no legacy BLUETOOTH permission either, and BLUETOOTH_CONNECT does not imply it — so the bonded list and connectGatt would have thrown on Android 11 and older too (minSdk is 28).
The fix
Ask, but only of people it helps. An uncaptured SC2 sits in lizard mode as a keyboard/mouse InputDevice, which needs no permission to enumerate, and Gamepad.kt already knew the BLE PID (PID_STEAMCONTROLLER2 = {0x1302, 0x1303}). The new Gamepad.sc2InputDevicePresent() keys on VID 0x28DE plus the SC2/Puck PIDs — deliberately not filtered by isPad, since lizard mode is never a gamepad source, which is exactly why every other pad-shaped query in the client steps past it. MainActivity asks once per process on that signal and engages the capture on the grant, so nobody without the hardware sees a Bluetooth prompt.
The Controllers screen offers the grant outright, since the probe reads a USB identity a BLE stack may not report. sc2BluetoothGrantOffered keeps that offer to the cases where it changes something — pure, and table-tested.
Sc2BleLink.CONNECT_PERMISSION is null below API 31. Gating on BLUETOOTH_CONNECT there is a permanent refusal rather than a redundancy: the platform does not know the name, so checkSelfPermission answers DENIED and a request is refused without a dialog. The legacy BLUETOOTH permission (maxSdkVersion="30") covers the same two calls there.
The link self-gates and logs when the permission is missing, so the next occurrence of this says so in logcat instead of looking like an absent controller.
Verification
:kit:testDebugUnitTest + :app:testDebugUnitTest green (5 new assertions in Sc2BluetoothGrantTest), manifest merge confirmed to carry both permissions.
Owed: on-glass with a BLE-paired SC2 — whether the prompt fires, whether NAME_HINTS matches what the SC2 advertises over BLE, and whether the InputDevice probe really sees PID 0x1303 on that transport.
Not in scope here
The same report's other half — wired/Puck picked up before connecting but "when I open the host it doesn't pick up anything" — is not addressed by this PR. The client stream path audited clean: the menu→stream handoff is synchronous (HidUsbLink.stop() joins the reader, releases the interfaces and closes the connection before the stream re-claims), streamHandle is set at StreamScreen.kt:386 before the handoff at :559 so no onResume can re-claim, configChanges already carries keyboard, and both gating settings default on. The prime suspect is host-side and already self-diagnosing: open_transport ladders usbip→UHID and warns "virtual Steam Controller 2 created as UHID — Steam WON'T list it … sudo modprobe vhci_hcd"; on that rung no kernel driver binds 28DE:1302, so there is no evdev node at all and Steam ignores UHID. Waiting on the tester's adb logcat -s Sc2Capture Sc2UsbLink Sc2BleLink and the host log before writing code for it.
Reported from the field: a Steam Controller 2 wired or on its Puck dongle changes the console UI to the controller layout, but the same pad paired over Bluetooth *"doesn't come up as a controller at all, doesn't change the layout to controller layout like the other two modes do"*.
## Root cause
The manifest declared `BLUETOOTH_CONNECT` and all three BLE gates tested for it — `MainActivity.startSc2MenuNav`, `StreamScreen`, and the Controllers screen — but **no code path in the client ever requested it**. Only mic, local-network and nearby-devices had permission launchers. It is a runtime permission from API 31, so every check answered `DENIED` forever and the BLE transport has been unreachable in every shipped build.
The failure was silent by construction: `pairedControllers()` wrapped `bondedDevices` in `runCatching{}.getOrDefault(emptyList())`, so the `SecurityException` from the missing permission read as *"no controller is paired"* — no log, no UI, nothing to grep. That is why this needed a field report rather than showing up in a log.
Second half of the same bug: the manifest carried no legacy `BLUETOOTH` permission either, and `BLUETOOTH_CONNECT` does **not** imply it — so the bonded list and `connectGatt` would have thrown on Android 11 and older too (minSdk is 28).
## The fix
- **Ask, but only of people it helps.** An uncaptured SC2 sits in lizard mode as a keyboard/mouse `InputDevice`, which needs no permission to enumerate, and `Gamepad.kt` already knew the BLE PID (`PID_STEAMCONTROLLER2 = {0x1302, 0x1303}`). The new `Gamepad.sc2InputDevicePresent()` keys on VID `0x28DE` plus the SC2/Puck PIDs — deliberately *not* filtered by `isPad`, since lizard mode is never a gamepad source, which is exactly why every other pad-shaped query in the client steps past it. `MainActivity` asks once per process on that signal and engages the capture on the grant, so nobody without the hardware sees a Bluetooth prompt.
- **The Controllers screen offers the grant outright**, since the probe reads a USB identity a BLE stack may not report. `sc2BluetoothGrantOffered` keeps that offer to the cases where it changes something — pure, and table-tested.
- **`Sc2BleLink.CONNECT_PERMISSION` is null below API 31.** Gating on `BLUETOOTH_CONNECT` there is a permanent refusal rather than a redundancy: the platform does not know the name, so `checkSelfPermission` answers DENIED and a request is refused without a dialog. The legacy `BLUETOOTH` permission (`maxSdkVersion="30"`) covers the same two calls there.
- **The link self-gates and logs when the permission is missing**, so the next occurrence of this says so in logcat instead of looking like an absent controller.
## Verification
`:kit:testDebugUnitTest` + `:app:testDebugUnitTest` green (5 new assertions in `Sc2BluetoothGrantTest`), manifest merge confirmed to carry both permissions.
**Owed: on-glass with a BLE-paired SC2** — whether the prompt fires, whether `NAME_HINTS` matches what the SC2 advertises over BLE, and whether the `InputDevice` probe really sees PID `0x1303` on that transport.
## Not in scope here
The same report's other half — wired/Puck picked up before connecting but *"when I open the host it doesn't pick up anything"* — is **not** addressed by this PR. The client stream path audited clean: the menu→stream handoff is synchronous (`HidUsbLink.stop()` joins the reader, releases the interfaces and closes the connection before the stream re-claims), `streamHandle` is set at `StreamScreen.kt:386` before the handoff at `:559` so no `onResume` can re-claim, `configChanges` already carries `keyboard`, and both gating settings default on. The prime suspect is host-side and already self-diagnosing: `open_transport` ladders usbip→UHID and warns *"virtual Steam Controller 2 created as UHID — Steam WON'T list it … `sudo modprobe vhci_hcd`"*; on that rung no kernel driver binds `28DE:1302`, so there is no evdev node at all and Steam ignores UHID. Waiting on the tester's `adb logcat -s Sc2Capture Sc2UsbLink Sc2BleLink` and the host log before writing code for it.
Reported from the field: an SC2 wired or on its Puck dongle changes the console UI to the
controller layout, but the same pad paired over Bluetooth "doesn't come up as a controller at
all".
The manifest declared BLUETOOTH_CONNECT and all three BLE gates tested for it — MainActivity's
menu capture, StreamScreen's stream capture, and the Controllers screen — but no code path in
the client ever REQUESTED it. It is a runtime permission from API 31, so the checks answered
DENIED forever and the BLE transport was unreachable in every shipped build. The failure was
silent by construction: `bondedDevices` behind a missing permission throws, and the enumeration
caught that into an empty list, which reads exactly like "no controller is paired".
- Ask for it, but only of people it helps: an uncaptured SC2 sits in lizard mode as a
keyboard/mouse InputDevice, which needs no permission to enumerate, so `sc2InputDevicePresent`
is the evidence that justifies the prompt. MainActivity asks once per process on that signal
and engages the capture on the grant.
- The Controllers screen offers the grant outright, since the probe reads a USB identity we
cannot assume a BLE stack reports. `sc2BluetoothGrantOffered` keeps that offer to the cases
where it changes something — pure, and table-tested.
- Declare the legacy BLUETOOTH permission below API 31. BLUETOOTH_CONNECT does not imply it, so
the bonded list and connectGatt would have thrown SecurityException on Android 11 and older
even once the runtime half worked.
- Gate the link on the permission itself and log when it is missing, so the next occurrence of
this says so in logcat instead of looking like an absent controller.
Verified: :kit:testDebugUnitTest + :app:testDebugUnitTest green (5 new), manifest merge carries
both permissions. On-glass with a BLE-paired SC2 is owed.
enricobuehler
scheduled this pull request to auto merge when all checks succeed 2026-08-15 14:25:27 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Reported from the field: a Steam Controller 2 wired or on its Puck dongle changes the console UI to the controller layout, but the same pad paired over Bluetooth "doesn't come up as a controller at all, doesn't change the layout to controller layout like the other two modes do".
Root cause
The manifest declared
BLUETOOTH_CONNECTand all three BLE gates tested for it —MainActivity.startSc2MenuNav,StreamScreen, and the Controllers screen — but no code path in the client ever requested it. Only mic, local-network and nearby-devices had permission launchers. It is a runtime permission from API 31, so every check answeredDENIEDforever and the BLE transport has been unreachable in every shipped build.The failure was silent by construction:
pairedControllers()wrappedbondedDevicesinrunCatching{}.getOrDefault(emptyList()), so theSecurityExceptionfrom the missing permission read as "no controller is paired" — no log, no UI, nothing to grep. That is why this needed a field report rather than showing up in a log.Second half of the same bug: the manifest carried no legacy
BLUETOOTHpermission either, andBLUETOOTH_CONNECTdoes not imply it — so the bonded list andconnectGattwould have thrown on Android 11 and older too (minSdk is 28).The fix
InputDevice, which needs no permission to enumerate, andGamepad.ktalready knew the BLE PID (PID_STEAMCONTROLLER2 = {0x1302, 0x1303}). The newGamepad.sc2InputDevicePresent()keys on VID0x28DEplus the SC2/Puck PIDs — deliberately not filtered byisPad, since lizard mode is never a gamepad source, which is exactly why every other pad-shaped query in the client steps past it.MainActivityasks once per process on that signal and engages the capture on the grant, so nobody without the hardware sees a Bluetooth prompt.sc2BluetoothGrantOfferedkeeps that offer to the cases where it changes something — pure, and table-tested.Sc2BleLink.CONNECT_PERMISSIONis null below API 31. Gating onBLUETOOTH_CONNECTthere is a permanent refusal rather than a redundancy: the platform does not know the name, socheckSelfPermissionanswers DENIED and a request is refused without a dialog. The legacyBLUETOOTHpermission (maxSdkVersion="30") covers the same two calls there.Verification
:kit:testDebugUnitTest+:app:testDebugUnitTestgreen (5 new assertions inSc2BluetoothGrantTest), manifest merge confirmed to carry both permissions.Owed: on-glass with a BLE-paired SC2 — whether the prompt fires, whether
NAME_HINTSmatches what the SC2 advertises over BLE, and whether theInputDeviceprobe really sees PID0x1303on that transport.Not in scope here
The same report's other half — wired/Puck picked up before connecting but "when I open the host it doesn't pick up anything" — is not addressed by this PR. The client stream path audited clean: the menu→stream handoff is synchronous (
HidUsbLink.stop()joins the reader, releases the interfaces and closes the connection before the stream re-claims),streamHandleis set atStreamScreen.kt:386before the handoff at:559so noonResumecan re-claim,configChangesalready carrieskeyboard, and both gating settings default on. The prime suspect is host-side and already self-diagnosing:open_transportladders usbip→UHID and warns "virtual Steam Controller 2 created as UHID — Steam WON'T list it …sudo modprobe vhci_hcd"; on that rung no kernel driver binds28DE:1302, so there is no evdev node at all and Steam ignores UHID. Waiting on the tester'sadb logcat -s Sc2Capture Sc2UsbLink Sc2BleLinkand the host log before writing code for it.