From ec675261fc2edec75a6f7015a410da4c27b13823 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 30 Jul 2026 18:29:08 +0200 Subject: [PATCH] feat(android): the Sony-pad USB grant is asked on connect, not found in Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On-glass feedback: burying the grant in the Controllers screen made the user go find it. Now MainActivity asks the moment a Sony pad appears — a fresh attach while the app is open, or the app foregrounding with one already plugged in — once per attach (a deny doesn't re-nag; the Controllers card's button stays as the re-ask). Nothing starts on the grant: an uncaptured pad is an ordinary InputDevice at menu time, so the grant is simply recorded and the next stream's capture engages silently. The grant broadcast is shared with the Controllers card so an open card refreshes from either dialog. Co-Authored-By: Claude Fable 5 --- .../io/unom/punktfunk/ControllersScreen.kt | 18 ++++++--- .../kotlin/io/unom/punktfunk/MainActivity.kt | 39 +++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) 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,