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 + } } }