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 342be705..239e80d0 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 @@ -329,10 +329,18 @@ private fun Sc2Row(usbDev: android.hardware.usb.UsbDevice?, activity: MainActivi } /** - * The Sony USB pad card — capture status + the USB grant, front-loading the permission dialog so - * the capture engages silently at stream start instead of interrupting it. Shown ALONGSIDE the - * pad's ordinary [PadRow] (unclaimed it is still an InputDevice); the capture itself only runs - * inside a stream, so at menu time this card is pure status. + * Broadcast action for the Sony-pad USB grants — fired by both the menu-time auto-ask + * ([MainActivity.maybeAskDsPermission]) and [DsRow]'s explicit button, so an open card + * refreshes whichever dialog was answered. + */ +internal const val DS_USB_PERMISSION_ACTION = "io.unom.punktfunk.DS_CONTROLLERS_USB_PERMISSION" + +/** + * The Sony USB pad card — capture status + the USB grant. The grant normally arrives via the + * menu-time auto-ask the moment the pad attaches ([MainActivity.maybeAskDsPermission]); the + * button here is the recovery path after a deny (the auto-ask fires once per attach). Shown + * ALONGSIDE the pad's ordinary [PadRow] (unclaimed it is still an InputDevice); the capture + * itself only runs inside a stream, so at menu time this card is pure status. */ @Composable private fun DsRow(usbDev: android.hardware.usb.UsbDevice) { @@ -349,7 +357,7 @@ private fun DsRow(usbDev: android.hardware.usb.UsbDevice) { } // Refresh `permitted` when the grant dialog answers (the grant itself is system-recorded; // this receiver only updates the card). - val action = "io.unom.punktfunk.DS_CONTROLLERS_USB_PERMISSION" + val action = DS_USB_PERMISSION_ACTION DisposableEffect(usbDev) { val receiver = object : android.content.BroadcastReceiver() { override fun onReceive(c: Context?, i: android.content.Intent?) { 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 33df51e1..91ecd366 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 @@ -28,6 +28,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import io.unom.punktfunk.kit.DsDevice import io.unom.punktfunk.kit.Gamepad import io.unom.punktfunk.kit.GamepadRouter import io.unom.punktfunk.kit.Keymap @@ -169,6 +170,10 @@ class MainActivity : ComponentActivity() { private var sc2Receiver: BroadcastReceiver? = null private var sc2PermissionAsked = 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 + /** * Compose focus hook for the SC2's synthetic D-pad (set by [onCreate]'s composition). A * synthetic KeyEvent dispatched from OUTSIDE the real input pipeline never reaches @@ -225,6 +230,8 @@ class MainActivity : ComponentActivity() { UsbManager.ACTION_USB_DEVICE_ATTACHED -> { sc2PermissionAsked = false // a fresh attach may ask once again startSc2MenuNav() + dsPermissionAsked = false + maybeAskDsPermission() } SC2_MENU_PERMISSION -> { if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) { @@ -281,6 +288,7 @@ class MainActivity : ComponentActivity() { override fun onResume() { super.onResume() startSc2MenuNav() + maybeAskDsPermission() } override fun onPause() { @@ -341,6 +349,37 @@ class MainActivity : ComponentActivity() { sc2MenuActive = false } + /** + * Ask for USB access to an attached Sony pad the moment it appears — a fresh attach while + * the app is open, or the app coming to the foreground with one already plugged in — at most + * once per attach, so the stream-mode capture ([io.unom.punktfunk.kit.DsCapture]) engages + * silently instead of interrupting stream start with the dialog. Unlike the SC2's menu flow + * there is nothing to START on the grant: an uncaptured Sony pad is an ordinary InputDevice + * at menu time, so the grant is simply recorded (Android keeps it while the pad stays + * attached). The broadcast only refreshes the Controllers screen's card if it happens to be + * open; a deny leaves that card's explicit button as the re-ask. + */ + private fun maybeAskDsPermission() { + if (streamHandle != 0L) return // StreamScreen owns its own permission flow while streaming + if (dsPermissionAsked) return + if (!SettingsStore(this).load().dsCapture) return + val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager + val dev = usbManager.deviceList.values.firstOrNull { + it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS + } ?: return + if (usbManager.hasPermission(dev)) return + dsPermissionAsked = true + usbManager.requestPermission( + dev, + PendingIntent.getBroadcast( + this, 4, // requestCode 4 — 0..3 are the SC2 stream/menu + DS stream/card grants + Intent(DS_USB_PERMISSION_ACTION).setPackage(packageName), + // MUTABLE: the USB stack appends the grant extras to this intent. + PendingIntent.FLAG_MUTABLE, + ), + ) + } + /** * One SC2 navigation key transition from the menu-time capture (main thread) — routed the * same way [dispatchKeyEvent]'s not-streaming branch routes a real pad's buttons: B backs,