From 60b652d5e2e04357d2f92d66829073d593997b6f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 15 Aug 2026 15:55:56 +0200 Subject: [PATCH] =?UTF-8?q?fix(android):=20a=20Bluetooth=20Steam=20Control?= =?UTF-8?q?ler=202=20was=20invisible=20=E2=80=94=20nothing=20ever=20asked?= =?UTF-8?q?=20for=20BLUETOOTH=5FCONNECT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../android/app/src/main/AndroidManifest.xml | 9 +- .../io/unom/punktfunk/ControllersScreen.kt | 114 +++++++++++++++++- .../kotlin/io/unom/punktfunk/MainActivity.kt | 50 +++++++- .../kotlin/io/unom/punktfunk/StreamScreen.kt | 7 +- .../unom/punktfunk/Sc2BluetoothGrantTest.kt | 98 +++++++++++++++ .../kotlin/io/unom/punktfunk/kit/Gamepad.kt | 23 ++++ .../io/unom/punktfunk/kit/Sc2BleLink.kt | 84 ++++++++++--- 7 files changed, 356 insertions(+), 29 deletions(-) create mode 100644 clients/android/app/src/test/kotlin/io/unom/punktfunk/Sc2BluetoothGrantTest.kt 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 + } } }