A Bluetooth Steam Controller 2 was invisible — nothing ever asked for BLUETOOTH_CONNECT (#251)
ci / bun-nix (push) Successful in 19s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 19s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 18s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / builders-arm64cross (push) Successful in 31s
ci / rust (push) Failing after 5m41s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m52s
docker / deploy-docs (push) Successful in 32s
ci / rust-arm64 (push) Successful in 7m20s
ci / web (push) Successful in 7m21s
ci / docs-site (push) Successful in 7m30s
android / android (push) Successful in 14m34s

This commit was merged in pull request #251.
This commit is contained in:
2026-08-15 14:25:29 +00:00
7 changed files with 356 additions and 29 deletions
@@ -28,9 +28,14 @@
<!-- Gamepad rumble feedback. -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Steam Controller 2 over direct BLE (Sc2BleLink talks Valve's vendor GATT service to the
bonded pad). A RUNTIME permission (NEARBY_DEVICES group); the capture engages only when
already granted — USB capture (wired / Puck dongle) needs no Bluetooth at all. -->
bonded pad). A RUNTIME permission (NEARBY_DEVICES group) from API 31 — MainActivity asks
for it when a BLE-paired SC2 is actually around, and the Controllers screen offers the
grant outright. USB capture (wired / Puck dongle) needs no Bluetooth at all.
Below API 31 the same two operations (the bonded list + connectGatt) are covered by the
install-time legacy permission instead, which BLUETOOTH_CONNECT does NOT imply — without
it every BLE capture on Android 11 and older throws SecurityException. -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<!-- We target phone + TV from day one: keep the app installable on TV (no touchscreen) and on
devices without a gamepad. -->
@@ -12,6 +12,8 @@ import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -54,6 +56,7 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import io.unom.punktfunk.kit.DsDevice
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.Sc2BleLink
import io.unom.punktfunk.kit.Sc2Capture
import kotlinx.coroutines.delay
@@ -373,12 +376,16 @@ private fun ControllersBody(
}
val sc2Probe = remember { Sc2Capture(context) }
val sc2Usb = remember(usbGeneration) { sc2Probe.findUsbDevice() }
val sc2Ble = remember(usbGeneration) {
if (context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
) sc2Probe.pairedBleAddress() else null
}
// Answers null without the Bluetooth grant (and logs why) — see Sc2BleLink.
val sc2Ble = remember(usbGeneration) { sc2Probe.pairedBleAddress() }
val sc2Present = sc2Usb != null || sc2Ble != null
// A BLE-paired SC2 cannot be seen at all until Bluetooth is granted, so "no controller
// detected" would be the wrong thing to print at someone who has one paired. This is the
// screen a user opens when a pad is missing, so the grant belongs here — see
// [sc2BluetoothGrantOffered] for when it is worth offering, and the lizard-mode
// InputDevice probe (no permission of its own) for how we word it.
val btPermitted = remember(usbGeneration) { Sc2BleLink.permissionGranted(context) }
val sc2OnBluetooth = remember(usbGeneration) { Gamepad.sc2InputDevicePresent() }
val dsUsb = remember(usbGeneration) {
(context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager)
.deviceList.values.firstOrNull {
@@ -399,6 +406,19 @@ private fun ControllersBody(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// After that paragraph on purpose: when nothing was detected, this is the actionable
// half of the same answer — the one pad we are blind to rather than one Android has
// simply classified oddly.
if (
sc2BluetoothGrantOffered(
permissionGranted = btPermitted,
usbSc2 = sc2Usb != null,
sc2Attached = sc2OnBluetooth,
anyPadDetected = pads.isNotEmpty(),
)
) {
Sc2BluetoothRow(attached = sc2OnBluetooth, activity = activity) { usbGeneration++ }
}
// Every real controller is forwarded now (Automatic forwards them all, each on its own
// wire pad index) — not just the first. A joystick-only device Android doesn't classify as
// a gamepad still can't be forwarded (the host wants a gamepad), so gate the badge on it.
@@ -461,6 +481,90 @@ private fun ControllersBody(
}
}
/**
* Whether to offer the Bluetooth grant for a directly-paired Steam Controller 2.
*
* Only when it could change the answer ([permissionGranted] false), and only when there is reason
* to think it would: an SC2 is visibly attached in lizard mode ([sc2Attached] — the permission-free
* probe), or nothing was detected at all ([anyPadDetected] false) and a Bluetooth SC2 is precisely
* the pad this client cannot see without the grant. A [usbSc2] is already captured over USB and
* needs no Bluetooth, and someone with working controllers and no sign of an SC2 is shown nothing.
*/
fun sc2BluetoothGrantOffered(
permissionGranted: Boolean,
usbSc2: Boolean,
sc2Attached: Boolean,
anyPadDetected: Boolean,
): Boolean = !permissionGranted && !usbSc2 && (sc2Attached || !anyPadDetected)
/**
* The Bluetooth grant for a directly-paired Steam Controller 2 — the card that exists because a
* BLE SC2 is invisible without it.
*
* A wired or Puck SC2 is enumerated over USB with no permission at all, so it shows up in this
* screen either way; the bonded list a BLE one lives in is behind `BLUETOOTH_CONNECT` from API 31
* and answers "nothing is paired" rather than "ask me first" when the permission is missing. Until
* this existed, nothing in the client ever requested it, so a Bluetooth SC2 was silently absent
* everywhere — no capture, no controller layout, no forwarding — while the same pad over USB
* worked (field report, 2026-08-15).
*
* [attached] distinguishes "we can see one sitting in lizard mode" from "you may have one paired",
* which is the difference between a statement and a guess. [onGranted] re-probes the caller's
* device state; the menu capture is engaged from here too, so the pad starts driving the UI on the
* grant rather than at the next resume.
*/
@Composable
private fun Sc2BluetoothRow(
attached: Boolean,
activity: MainActivity?,
onGranted: () -> Unit,
) {
val context = LocalContext.current
val settingOn = remember { SettingsStore(context).load().sc2Capture }
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
activity?.startSc2MenuNav()
onGranted()
}
}
val permission = Sc2BleLink.CONNECT_PERMISSION ?: return
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
if (attached) "Steam Controller 2" else "Steam Controller 2 over Bluetooth",
style = MaterialTheme.typography.bodyLarge,
)
Text(
when {
!settingOn ->
"Passthrough is disabled in Settings — enable \"Steam Controller 2 " +
"passthrough\" to capture it."
attached ->
"Paired over Bluetooth. Punktfunk needs Bluetooth access to capture it — " +
"until then it stays in its built-in keyboard/mouse mode and no game " +
"sees a controller."
else ->
"A Steam Controller 2 paired over Bluetooth can't be detected without " +
"Bluetooth access. Wired and Puck-dongle controllers need no " +
"permission and are already listed above."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (settingOn) {
OutlinedButton(onClick = { launcher.launch(permission) }) {
Text("Grant Bluetooth access")
}
}
}
}
}
/**
* The Steam Controller 2 card — capture-side state, since a (claimed or lizard-mode) SC2 never
* appears as a gamepad InputDevice. Shows the transport, whether the capture is live (driving
@@ -34,6 +34,7 @@ import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.GamepadRouter
import io.unom.punktfunk.kit.Keymap
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.Sc2BleLink
import io.unom.punktfunk.kit.SessionAccess
import io.unom.punktfunk.kit.link.DeepLinkResult
import io.unom.punktfunk.kit.link.DeepLinks
@@ -43,6 +44,9 @@ import io.unom.punktfunk.kit.security.KnownHostStore
/** Broadcast action for the menu-time SC2 USB-permission grant (see [MainActivity.startSc2MenuNav]). */
private const val SC2_MENU_PERMISSION = "io.unom.punktfunk.SC2_MENU_USB_PERMISSION"
/** Request code for the SC2's Bluetooth grant (see [MainActivity.maybeAskSc2BtPermission]). */
private const val REQ_SC2_BLUETOOTH = 0x5C2B
/**
* Keeps ONE window-insets reader alive for as long as the app's UI exists — the fix for the menus
* coming back from a stream laid out against the WRONG safe area.
@@ -192,6 +196,9 @@ class MainActivity : ComponentActivity() {
private var sc2Receiver: BroadcastReceiver? = null
private var sc2PermissionAsked = false
/** Bluetooth asked once this process — a denial must not re-prompt on every resume. */
private var sc2BtPermissionAsked = false
/** Sony-pad USB grant asked this attach — a deny doesn't re-nag until a fresh attach (or the
* Controllers screen's explicit button). */
private var dsPermissionAsked = false
@@ -330,7 +337,8 @@ class MainActivity : ComponentActivity() {
* Engage the menu-time SC2 capture if possible: setting on, not streaming, and a wired/Puck
* pad attached (asking for USB permission at most once per attach — [forceAsk] re-arms the
* dialog, for the Controllers screen's explicit grant button) — else an already-paired BLE
* controller when BLUETOOTH_CONNECT is granted. Safe to call repeatedly.
* controller, asking for Bluetooth access once if one appears to be attached
* ([maybeAskSc2BtPermission]). Safe to call repeatedly.
*/
fun startSc2MenuNav(forceAsk: Boolean = false) {
if (forceAsk) sc2PermissionAsked = false
@@ -358,10 +366,46 @@ class MainActivity : ComponentActivity() {
),
)
}
dev == null && checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
PackageManager.PERMISSION_GRANTED -> {
dev == null && Sc2BleLink.permissionGranted(this) -> {
cap.pairedBleAddress()?.let { cap.startBle(it) }
}
dev == null -> maybeAskSc2BtPermission()
}
}
/**
* Ask for Bluetooth access when a BLE-paired SC2 looks like it is attached and we cannot see
* it — once per process, and never on a device that shows no sign of owning one.
*
* The permission is the whole reason a Bluetooth SC2 used to go unnoticed: the bonded list and
* `connectGatt` both need it from API 31, nothing in the client had ever requested it, and the
* bonded-list call answers an empty list rather than an error when it is missing — so the
* capture stood down silently and the console UI never flipped to its controller layout, while
* the same pad over USB worked (field report, 2026-08-15). Asking is gated on
* [Gamepad.sc2InputDevicePresent] because an uncaptured SC2 sits in lizard mode as a
* keyboard/mouse [android.view.InputDevice] — visible without any permission at all — so the
* prompt reaches the people who have the hardware and nobody else.
*/
private fun maybeAskSc2BtPermission() {
val permission = Sc2BleLink.CONNECT_PERMISSION ?: return // granted at install time here
if (sc2BtPermissionAsked) return
if (!Gamepad.sc2InputDevicePresent()) return
sc2BtPermissionAsked = true
requestPermissions(arrayOf(permission), REQ_SC2_BLUETOOTH)
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray,
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
// Engage immediately on the grant — the pad is already paired, so there is nothing else to
// wait for and the user just told us what they want it for.
if (requestCode == REQ_SC2_BLUETOOTH &&
grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED
) {
startSc2MenuNav()
}
}
@@ -691,8 +691,11 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
),
)
}
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
PackageManager.PERMISSION_GRANTED -> {
// No USB pad: fall back to a bonded BLE one. The Bluetooth-permission gate lives
// inside pairedBleAddress() (it answers null, and says why, when the grant is
// missing) rather than being restated here — the grant itself is asked for where
// a user can act on it, in the console UI and the Controllers screen.
else -> {
sc2.pairedBleAddress()?.let { addr ->
Log.i("punktfunk", "SC2: no USB pad — using the paired BLE controller $addr")
sc2.startBle(addr)
@@ -0,0 +1,98 @@
package io.unom.punktfunk
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* [sc2BluetoothGrantOffered] is pure table-tested over its inputs.
*
* The rule exists because a Steam Controller 2 paired over Bluetooth is invisible to this client
* until `BLUETOOTH_CONNECT` is granted (the bonded list answers "nothing is paired" rather than
* refusing), and nothing ever asked for it so the pad silently never engaged while the same
* controller over USB worked. The offer has to reach those users without becoming a Bluetooth
* prompt for everyone else, which is the whole content of these assertions.
*/
class Sc2BluetoothGrantTest {
/** The reported case: an SC2 sitting in lizard mode that we cannot capture. */
@Test
fun offeredWhenAnSc2IsAttachedButBluetoothIsNot() {
assertTrue(
sc2BluetoothGrantOffered(
permissionGranted = false,
usbSc2 = false,
sc2Attached = true,
anyPadDetected = false,
),
)
// Still offered next to other working pads — the SC2 is the one we can't reach.
assertTrue(
sc2BluetoothGrantOffered(
permissionGranted = false,
usbSc2 = false,
sc2Attached = true,
anyPadDetected = true,
),
)
}
/**
* The probe reads an SC2's USB identity, which we cannot assume a BLE stack reports. When it
* misses, "no controller detected" is exactly when a blind spot is worth naming.
*/
@Test
fun offeredWhenNothingWasDetectedAtAll() {
assertTrue(
sc2BluetoothGrantOffered(
permissionGranted = false,
usbSc2 = false,
sc2Attached = false,
anyPadDetected = false,
),
)
}
/** Never a prompt for someone with working controllers and no sign of an SC2. */
@Test
fun notOfferedToUsersWithNoSignOfAnSc2() {
assertFalse(
sc2BluetoothGrantOffered(
permissionGranted = false,
usbSc2 = false,
sc2Attached = false,
anyPadDetected = true,
),
)
}
/** Granting it changes nothing that is already captured over USB — wired and Puck alike. */
@Test
fun notOfferedWhenTheSc2IsOnUsb() {
assertFalse(
sc2BluetoothGrantOffered(
permissionGranted = false,
usbSc2 = true,
sc2Attached = true,
anyPadDetected = false,
),
)
}
/** Nothing to ask for once it is held — including on releases that grant it at install time. */
@Test
fun notOfferedOncePermitted() {
for (attached in listOf(true, false)) {
for (pads in listOf(true, false)) {
assertFalse(
sc2BluetoothGrantOffered(
permissionGranted = true,
usbSc2 = false,
sc2Attached = attached,
anyPadDetected = pads,
),
)
}
}
}
}
@@ -195,6 +195,29 @@ object Gamepad {
/** First connected gamepad/joystick [InputDevice], or null when none is attached. */
fun firstPad(): InputDevice? = pads().firstOrNull()
/**
* True when a Steam Controller 2 is attached as an ORDINARY [InputDevice] which, for a pad
* this client wants to capture, means an uncaptured one still in lizard mode.
*
* Deliberately not filtered by [isPad]: lizard mode emulates a keyboard and mouse, so an SC2
* is never a gamepad source and every other pad-shaped query in the client steps right past
* it. That is also why this is worth having a wired or Puck SC2 is found by enumerating USB
* (no permission needed), but a BLE-paired one is invisible until `BLUETOOTH_CONNECT` is
* granted, and asking for Bluetooth on the chance that someone might own one is not something
* to put in front of every user. This is the permission-free signal that the pad is genuinely
* there, so the request can be made to the people it helps and to nobody else.
*
* A false negative is survivable by design (the Controllers screen offers the grant outright),
* so this matches only the identities we know rather than reaching for every Valve device a
* Steam Deck's own controller and a classic Steam Controller are not SC2s and must not
* conjure a Bluetooth prompt.
*/
fun sc2InputDevicePresent(): Boolean =
InputDevice.getDeviceIds().asSequence().mapNotNull { InputDevice.getDevice(it) }.any {
it.vendorId == VID_VALVE &&
(it.productId in PID_STEAMCONTROLLER2 || it.productId in PID_STEAMCONTROLLER2_PUCK)
}
/**
* The [GamepadPref] wire byte to send for the user's [setting] (the persisted gamepad index). A
* non-Auto setting is passed through unchanged; "Automatic" ([PREF_AUTO]) resolves to a concrete
@@ -1,5 +1,6 @@
package io.unom.punktfunk.kit
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
@@ -9,6 +10,8 @@ import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
@@ -47,16 +50,33 @@ class Sc2BleLink(
@Volatile private var state = State.IDLE
/** Bonded devices that look like a Steam Controller (name heuristic — BLE exposes no PID here). */
fun pairedControllers(): List<BluetoothDevice> = runCatching {
manager.adapter?.bondedDevices.orEmpty().filter { dev ->
val n = runCatching { dev.name }.getOrNull() ?: return@filter false
NAME_HINTS.any { n.contains(it, ignoreCase = true) }
/**
* Bonded devices that look like a Steam Controller (name heuristic BLE exposes no PID here).
*
* Gates on [permissionGranted] itself rather than trusting callers to: without the permission
* `bondedDevices` throws, and the `runCatching` below turns that into an empty list
* indistinguishable from "no controller is paired". A capture that never engaged for want of a
* permission nobody had asked for is exactly the silence this logs its way out of.
*/
fun pairedControllers(): List<BluetoothDevice> {
if (!permissionGranted(context)) {
Log.i(TAG, "BLE controllers not enumerated: $CONNECT_PERMISSION not granted")
return emptyList()
}
}.getOrDefault(emptyList())
return runCatching {
manager.adapter?.bondedDevices.orEmpty().filter { dev ->
val n = runCatching { dev.name }.getOrNull() ?: return@filter false
NAME_HINTS.any { n.contains(it, ignoreCase = true) }
}
}.getOrDefault(emptyList())
}
/** Connect to the bonded controller at [address]. Reports start flowing once READY. */
fun start(address: String): Boolean {
if (!permissionGranted(context)) {
Log.i(TAG, "BLE capture not started: $CONNECT_PERMISSION not granted")
return false
}
val adapter = manager.adapter ?: return false
if (!adapter.isEnabled) return false
val device = runCatching { adapter.getRemoteDevice(address) }.getOrNull() ?: return false
@@ -222,20 +242,50 @@ class Sc2BleLink(
return s.substring(0, 8).toLongOrNull(16)
}
private companion object {
const val TAG = "Sc2BleLink"
companion object {
private const val TAG = "Sc2BleLink"
val VALVE_SERVICE: UUID = UUID.fromString("100f6c32-1735-4313-b402-38567131e5f3")
const val VALVE_UUID_TAIL = "-1735-4313-b402-38567131e5f3"
const val NOTIFY_LOW = 0x100f6c75L
const val NOTIFY_HIGH = 0x100f6c7aL
const val WRITE_LOW = 0x100f6cb5L
const val WRITE_HIGH = 0x100f6cbeL
val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
private val VALVE_SERVICE: UUID = UUID.fromString("100f6c32-1735-4313-b402-38567131e5f3")
private const val VALVE_UUID_TAIL = "-1735-4313-b402-38567131e5f3"
private const val NOTIFY_LOW = 0x100f6c75L
private const val NOTIFY_HIGH = 0x100f6c7aL
private const val WRITE_LOW = 0x100f6cb5L
private const val WRITE_HIGH = 0x100f6cbeL
private val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
val NAME_HINTS = listOf("Steam Ctrl", "Steam Controller", "SteamController", "Valve")
private val NAME_HINTS =
listOf("Steam Ctrl", "Steam Controller", "SteamController", "Valve")
/** Enough for a state payload (45 B) + ATT header with margin. */
const val DESIRED_MTU = 100
private const val DESIRED_MTU = 100
/**
* The runtime permission this transport needs, or null where the platform grants Bluetooth
* at install time.
*
* From API 31 both operations a capture makes reading the bonded list and `connectGatt`
* sit behind the runtime `BLUETOOTH_CONNECT`. Below it the manifest's legacy `BLUETOOTH`
* (normal-level, granted on install) covers exactly those two, and `BLUETOOTH_CONNECT` is
* not a permission that platform version knows: `checkSelfPermission` answers DENIED for
* it and a request is refused without a dialog. Gating on it unconditionally is therefore
* not merely redundant on old releases it is a permanent refusal, which is what this
* null arm exists to avoid.
*/
val CONNECT_PERMISSION: String? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
Manifest.permission.BLUETOOTH_CONNECT
} else {
null
}
/**
* Whether a BLE capture may run: [CONNECT_PERMISSION] held, or not required on this
* release. Callers that can offer the user a grant ask this first, so the offer appears
* only when it would change something.
*/
fun permissionGranted(context: Context): Boolean {
val permission = CONNECT_PERMISSION ?: return true
return context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
}
}
}