car audio: explain a blocked microphone permission instead of failing silently

This commit is contained in:
Seb3thehacker committed 2026-10-11 20:01:36 -07:00
1 parent fff2db63b0
commit 4fe9421884
4 files changed
+74 -2

No files matched your search

@@ -8,8 +8,10 @@ import android.hardware.usb.UsbAccessory
import android.hardware.usb.UsbManager import android.hardware.usb.UsbManager
import android.media.projection.MediaProjection import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager import android.media.projection.MediaProjectionManager
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.provider.Settings
import android.view.MotionEvent import android.view.MotionEvent
import android.view.Surface import android.view.Surface
import android.view.WindowManager import android.view.WindowManager
@@ -74,7 +76,13 @@ class GearslipActivity : ComponentActivity(), Projection {
private val audioPermission = registerForActivityResult( private val audioPermission = registerForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(), androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
) { granted -> ) { 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") GearslipLog.w("audio: record permission declined")
CarAudio.denied() 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]. */ /** Raised once, after the first "no" to the capture dialog; see [AudioChoiceDialog]. */
private val offerBluetooth = kotlinx.coroutines.flow.MutableStateFlow(false) private val offerBluetooth = kotlinx.coroutines.flow.MutableStateFlow(false)
@@ -148,6 +159,18 @@ class GearslipActivity : ComponentActivity(), Projection {
onKeepAsking = ::close, 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) Prefetch.warm(this)
UsageStats.onAppOpened(this) UsageStats.onAppOpened(this)
app.seb3thehacker.gearslip.update.UpdateChecker.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) { override fun onNewIntent(intent: Intent) {
@@ -100,6 +100,26 @@ object CarAudio {
_capture.value = Capture.DENIED _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<Boolean> = _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() { internal fun captureStarted() {
_capture.value = Capture.ON _capture.value = Capture.ON
link?.start() link?.start()
@@ -449,10 +449,13 @@ private sealed interface LyricsState {
@Composable @Composable
private fun AudioBadge(capture: CarAudio.Capture) { private fun AudioBadge(capture: CarAudio.Capture) {
val micBlocked by CarAudio.micBlocked.collectAsStateWithLifecycle()
val text = when (capture) { val text = when (capture) {
CarAudio.Capture.ON -> "Audio to car" CarAudio.Capture.ON -> "Audio to car"
CarAudio.Capture.ASKING -> "Allow audio capture on the phone" 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 CarAudio.Capture.OFF -> return
} }
Text(text, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(text, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
@@ -47,3 +47,25 @@ fun AudioChoiceDialog(onUseBluetooth: () -> Unit, onKeepAsking: () -> Unit) {
dismissButton = { TextButton(onClick = onKeepAsking) { Text("Keep asking") } }, 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") } },
)
}