From 4fe9421884ab30c7fbb79bb908cd7913de583bbc Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Sun, 11 Oct 2026 20:01:36 -0700 Subject: [PATCH] car audio: explain a blocked microphone permission instead of failing silently --- .../gearslip/GearslipActivity.kt | 29 ++++++++++++++++++- .../seb3thehacker/gearslip/audio/CarAudio.kt | 20 +++++++++++++ .../seb3thehacker/gearslip/car/MediaScreen.kt | 5 +++- .../gearslip/ui/AudioChoiceDialog.kt | 22 ++++++++++++++ 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt index b74175a..34fccc6 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt @@ -8,8 +8,10 @@ import android.hardware.usb.UsbAccessory import android.hardware.usb.UsbManager import android.media.projection.MediaProjection import android.media.projection.MediaProjectionManager +import android.net.Uri import android.os.Bundle import android.os.ParcelFileDescriptor +import android.provider.Settings import android.view.MotionEvent import android.view.Surface import android.view.WindowManager @@ -74,7 +76,13 @@ class GearslipActivity : ComponentActivity(), Projection { private val audioPermission = registerForActivityResult( androidx.activity.result.contract.ActivityResultContracts.RequestPermission(), ) { granted -> - if (granted) askAudioCapture() else { + if (granted) askAudioCapture() + else if (!shouldShowRequestPermissionRationale(android.Manifest.permission.RECORD_AUDIO)) { + // Refused twice: Android now says no without showing anything, so the phone must. + GearslipLog.w("audio: record permission blocked - Android no longer asks") + CarAudio.micBlocked() + micBlockedNotice.value = true + } else { GearslipLog.w("audio: record permission declined") CarAudio.denied() } @@ -91,6 +99,9 @@ class GearslipActivity : ComponentActivity(), Projection { } } + /** Raised when the microphone permission is blocked; see [MicBlockedDialog]. */ + private val micBlockedNotice = kotlinx.coroutines.flow.MutableStateFlow(false) + /** Raised once, after the first "no" to the capture dialog; see [AudioChoiceDialog]. */ private val offerBluetooth = kotlinx.coroutines.flow.MutableStateFlow(false) @@ -148,6 +159,18 @@ class GearslipActivity : ComponentActivity(), Projection { onKeepAsking = ::close, ) } + val micNotice by micBlockedNotice.collectAsState() + if (micNotice) { + app.seb3thehacker.gearslip.ui.MicBlockedDialog( + onOpenSettings = { + micBlockedNotice.value = false + startActivity( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", packageName, null)), + ) + }, + onDismiss = { micBlockedNotice.value = false }, + ) + } } } @@ -173,6 +196,10 @@ class GearslipActivity : ComponentActivity(), Projection { Prefetch.warm(this) UsageStats.onAppOpened(this) app.seb3thehacker.gearslip.update.UpdateChecker.onAppOpened(this) + // Back from the app's settings with the microphone turned on: try car audio again. + if (checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == android.content.pm.PackageManager.PERMISSION_GRANTED) { + CarAudio.micAllowed() + } } override fun onNewIntent(intent: Intent) { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/CarAudio.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/CarAudio.kt index 13a0073..21c6cf2 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/CarAudio.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/CarAudio.kt @@ -100,6 +100,26 @@ object CarAudio { _capture.value = Capture.DENIED } + private val _micBlocked = MutableStateFlow(false) + + /** The microphone permission is off for good: Android refuses without asking, so say why. */ + val micBlocked: StateFlow = _micBlocked.asStateFlow() + + fun micBlocked() { + _micBlocked.value = true + denied() + } + + /** The driver turned the permission back on mid-drive: ask for capture again. */ + fun micAllowed() { + if (!_micBlocked.value) return + _micBlocked.value = false + if (_capture.value == Capture.DENIED) { + _capture.value = Capture.OFF + if (wanted) request() + } + } + internal fun captureStarted() { _capture.value = Capture.ON link?.start() diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt index 40fc17e..b099efa 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt @@ -449,10 +449,13 @@ private sealed interface LyricsState { @Composable private fun AudioBadge(capture: CarAudio.Capture) { + val micBlocked by CarAudio.micBlocked.collectAsStateWithLifecycle() val text = when (capture) { CarAudio.Capture.ON -> "Audio to car" CarAudio.Capture.ASKING -> "Allow audio capture on the phone" - CarAudio.Capture.DENIED -> "Audio stays on the phone" + CarAudio.Capture.DENIED -> + if (micBlocked) "Audio stays on the phone: turn on Gearslip's microphone permission" + else "Audio stays on the phone" CarAudio.Capture.OFF -> return } Text(text, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/AudioChoiceDialog.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/AudioChoiceDialog.kt index f53ecde..0314586 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/AudioChoiceDialog.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/AudioChoiceDialog.kt @@ -47,3 +47,25 @@ fun AudioChoiceDialog(onUseBluetooth: () -> Unit, onKeepAsking: () -> Unit) { dismissButton = { TextButton(onClick = onKeepAsking) { Text("Keep asking") } }, ) } + +/** + * Android lets an app capture other apps' audio only while it holds the microphone permission. + * Once the driver has refused that twice, Android stops asking, and music silently stays on the + * phone; this says why and where to turn it back on. + */ +@Composable +fun MicBlockedDialog(onOpenSettings: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Car audio needs the microphone permission") }, + text = { + Text( + "Android lets an app send other apps' sound to the car only if it has the " + + "microphone permission. It's turned off for Gearslip, so music plays on the " + + "phone. Turn it on under Permissions, then Microphone.", + ) + }, + confirmButton = { TextButton(onClick = onOpenSettings) { Text("Open app settings") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Not now") } }, + ) +}