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
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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user