car audio: choose Bluetooth or USB, offer Bluetooth after a refused screen share

USB audio needs Android's screen-sharing consent every drive. A new Car audio setting on the phone and car screens picks Bluetooth instead, and the first refusal opens a dialog offering Bluetooth, asking again, or the one-time adb command.
This commit is contained in:
Seb3thehacker committed 2026-10-11 11:34:06 -07:00
1 parent f9e38cbc45
commit 261e6a719a
5 files changed
+122 -6

No files matched your search

@@ -15,6 +15,8 @@ import android.view.Surface
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import android.content.res.Configuration
@@ -85,9 +87,13 @@ class GearslipActivity : ComponentActivity(), Projection {
} else {
GearslipLog.w("audio: capture consent declined")
CarAudio.denied()
if (!CarSettings.audioChoiceAsked) offerBluetooth.value = true
}
}
/** Raised once, after the first "no" to the capture dialog; see [AudioChoiceDialog]. */
private val offerBluetooth = kotlinx.coroutines.flow.MutableStateFlow(false)
private val detachReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == UsbManager.ACTION_USB_ACCESSORY_DETACHED) {
@@ -128,6 +134,20 @@ class GearslipActivity : ComponentActivity(), Projection {
setContent {
GearslipTheme {
GearslipApp(onDisconnect = ::closeAccessory, onRequestCallScreening = ::requestCallScreeningRole)
val offer by offerBluetooth.collectAsState()
if (offer) {
fun close() {
CarSettings.markAudioChoiceAsked()
offerBluetooth.value = false
}
app.seb3thehacker.gearslip.ui.AudioChoiceDialog(
onUseBluetooth = {
CarSettings.setPipeAudio(false)
close()
},
onKeepAsking = ::close,
)
}
}
}
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.car
import android.content.Context
import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.audio.CarAudio
import app.seb3thehacker.gearslip.car.theme.GsTheme
import app.seb3thehacker.gearslip.car.theme.GsThemes
import kotlinx.coroutines.flow.MutableStateFlow
@@ -27,6 +28,7 @@ object CarSettings {
private const val KEY_OPEN = "car_open_on_connect"
private const val KEY_NIGHT = "car_night_mode"
private const val KEY_PIPE_AUDIO = "car_pipe_audio"
private const val KEY_AUDIO_CHOICE_ASKED = "car_audio_choice_asked"
private const val KEY_LAST_NAV = "car_last_nav"
private const val KEY_LAST_MEDIA = "car_last_media"
private const val KEY_AUTOPLAY = "car_autoplay"
@@ -148,10 +150,20 @@ object CarSettings {
* Whether a media app's sound is captured and sent over the car link. On by default, since
* sound in the car is the point of the exercise; the platform allows the capture only behind a
* consent dialog, which [app.seb3thehacker.gearslip.audio.CarAudio] raises once the car has
* somewhere to play it. Turned off, the app plays through the phone's normal output instead.
* somewhere to play it. Turned off ("Bluetooth" in the settings), the app plays through the
* phone's normal output instead, and Android never asks.
*/
val pipeAudio: StateFlow<Boolean> = pipeAudioFlow
/**
* Whether the driver has already been offered Bluetooth after turning down the capture
* dialog. Offered once only: someone who chose to keep the USB route shouldn't be nagged.
*/
val audioChoiceAsked: Boolean
get() = AppSettings.getString(app, KEY_AUDIO_CHOICE_ASKED, "false") == "true"
fun markAudioChoiceAsked() = AppSettings.putString(app, KEY_AUDIO_CHOICE_ASKED, "true")
/**
* Off by default: a driver has to opt into experimental features as a whole before any of
* them - the voice assistant included - show up to turn on individually.
@@ -265,9 +277,15 @@ object CarSettings {
AppSettings.putString(app, KEY_AUTOPLAY, on.toString())
}
/**
* Takes effect at once: switching to Bluetooth mid-drive stops the capture, and switching
* back asks for it again (or waits for the car, if none is connected).
*/
fun setPipeAudio(on: Boolean) {
if (pipeAudioFlow.value == on) return
pipeAudioFlow.value = on
AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString())
if (on) CarAudio.request() else CarAudio.release()
}
fun setExperimentalFeaturesEnabled(on: Boolean) {
@@ -126,14 +126,14 @@ fun CarSettingsScreen() {
) { CarSettings.setAutoplay(it) }
ChoiceRow(
"Media audio",
listOf(false to "Phone audio", true to "Car audio"),
"Car audio",
listOf(false to "Bluetooth", true to "USB"),
pipeAudio,
) { CarSettings.setPipeAudio(it) }
Text(
"Car audio sends the phone's sound to the car over USB. Android asks for permission " +
"when the car connects, and a few apps block it. Phone audio plays as the phone " +
"normally would, for example over Bluetooth to the car.",
"USB sends the phone's sound through Gearslip. Android asks to share your screen each " +
"drive, and a few apps block it. Bluetooth plays as the phone normally would, and " +
"Android never asks.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -0,0 +1,49 @@
package app.seb3thehacker.gearslip.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.BuildConfig
/**
* Shown once, the first time the driver turns down Android's screen-sharing dialog: that dialog
* is the price of sending music over USB, and most people who refuse it would rather not see it
* again. Offers the two ways out: Bluetooth, or the one-time adb command.
*/
@Composable
fun AudioChoiceDialog(onUseBluetooth: () -> Unit, onKeepAsking: () -> Unit) {
var showCommand by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onKeepAsking,
title = { Text("Play car audio over Bluetooth?") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"Android treats music sent over USB like sharing your screen, so it asks every " +
"drive. Over Bluetooth it never asks. You can change this later in Settings.",
)
if (showCommand) {
Text(
"Or keep USB and stop the question for good: plug the phone into a " +
"computer with USB debugging on, and run this once.",
style = MaterialTheme.typography.bodyMedium,
)
CopyableCommand("adb shell appops set ${BuildConfig.APPLICATION_ID} PROJECT_MEDIA allow")
} else {
TextButton(onClick = { showCommand = true }) { Text("Never ask, using a computer") }
}
}
},
confirmButton = { TextButton(onClick = onUseBluetooth) { Text("Use Bluetooth") } },
dismissButton = { TextButton(onClick = onKeepAsking) { Text("Keep asking") } },
)
}
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
@@ -33,12 +34,14 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -55,6 +58,7 @@ import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.BuildConfig
import app.seb3thehacker.gearslip.CertProvider
import app.seb3thehacker.gearslip.car.CarSettings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import androidx.compose.runtime.LaunchedEffect
@@ -103,6 +107,7 @@ fun SettingsScreen(
Spacer(Modifier.height(0.dp))
AboutSection(onOpenWhatsNew, onReplayTutorial)
ConnectionSection()
CarAudioSection()
if (SHOW_RECOMMENDED_APPS) AppsSection(onOpenRecommendedApps)
CertificateSection()
SettingsSection("Usage notes", header = USAGE_STATS_INTRO) { UsageStatsRows() }
@@ -163,6 +168,30 @@ private fun ConnectionSection() {
}
}
/** How music reaches the car: through Gearslip over USB, or by Bluetooth as usual. */
@Composable
private fun CarAudioSection() {
val usb by CarSettings.pipeAudio.collectAsState()
SettingsSection(
"Car audio",
footer = "Run the command in Help once, and USB stops asking.",
) {
SettingsRow(
"USB",
Modifier.selectable(usb, role = Role.RadioButton) { CarSettings.setPipeAudio(true) },
subtitle = "Sound goes through Gearslip. Android asks to share your screen each drive",
trailing = { RadioButton(selected = usb, onClick = null) },
)
SettingsDivider()
SettingsRow(
"Bluetooth",
Modifier.selectable(!usb, role = Role.RadioButton) { CarSettings.setPipeAudio(false) },
subtitle = "Sound plays over the phone's Bluetooth link to the car. Android never asks",
trailing = { RadioButton(selected = !usb, onClick = null) },
)
}
}
@Composable
private fun AppsSection(onOpenRecommendedApps: () -> Unit) {
SettingsSection("Apps") {