diff --git a/clients/android/app/src/main/AndroidManifest.xml b/clients/android/app/src/main/AndroidManifest.xml
index c30c953f..47569bd0 100644
--- a/clients/android/app/src/main/AndroidManifest.xml
+++ b/clients/android/app/src/main/AndroidManifest.xml
@@ -28,9 +28,14 @@
+ 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. -->
+
diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt
index fff2514e..940fa338 100644
--- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt
+++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt
@@ -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
diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt
index cf246ac2..42cea36c 100644
--- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt
+++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt
@@ -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,
+ 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()
}
}
diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt
index eaa3065c..8ce01ad5 100644
--- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt
+++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt
@@ -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)
diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/Sc2BluetoothGrantTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/Sc2BluetoothGrantTest.kt
new file mode 100644
index 00000000..2e04f809
--- /dev/null
+++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/Sc2BluetoothGrantTest.kt
@@ -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,
+ ),
+ )
+ }
+ }
+ }
+}
diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt
index d740c4a8..b86cb064 100644
--- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt
+++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt
@@ -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
diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Sc2BleLink.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Sc2BleLink.kt
index 2f19fc06..a1000ffa 100644
--- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Sc2BleLink.kt
+++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Sc2BleLink.kt
@@ -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 = 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 {
+ 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
+ }
}
}