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 android.view.WindowManager
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import android.content.res.Configuration import android.content.res.Configuration
@@ -85,9 +87,13 @@ class GearslipActivity : ComponentActivity(), Projection {
} else { } else {
GearslipLog.w("audio: capture consent declined") GearslipLog.w("audio: capture consent declined")
CarAudio.denied() 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() { private val detachReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == UsbManager.ACTION_USB_ACCESSORY_DETACHED) { if (intent?.action == UsbManager.ACTION_USB_ACCESSORY_DETACHED) {
@@ -128,6 +134,20 @@ class GearslipActivity : ComponentActivity(), Projection {
setContent { setContent {
GearslipTheme { GearslipTheme {
GearslipApp(onDisconnect = ::closeAccessory, onRequestCallScreening = ::requestCallScreeningRole) 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 android.content.Context
import app.seb3thehacker.gearslip.AppSettings import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.audio.CarAudio
import app.seb3thehacker.gearslip.car.theme.GsTheme import app.seb3thehacker.gearslip.car.theme.GsTheme
import app.seb3thehacker.gearslip.car.theme.GsThemes import app.seb3thehacker.gearslip.car.theme.GsThemes
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -27,6 +28,7 @@ object CarSettings {
private const val KEY_OPEN = "car_open_on_connect" private const val KEY_OPEN = "car_open_on_connect"
private const val KEY_NIGHT = "car_night_mode" private const val KEY_NIGHT = "car_night_mode"
private const val KEY_PIPE_AUDIO = "car_pipe_audio" 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_NAV = "car_last_nav"
private const val KEY_LAST_MEDIA = "car_last_media" private const val KEY_LAST_MEDIA = "car_last_media"
private const val KEY_AUTOPLAY = "car_autoplay" 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 * 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 * 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 * 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 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 * 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. * them - the voice assistant included - show up to turn on individually.
@@ -265,9 +277,15 @@ object CarSettings {
AppSettings.putString(app, KEY_AUTOPLAY, on.toString()) 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) { fun setPipeAudio(on: Boolean) {
if (pipeAudioFlow.value == on) return
pipeAudioFlow.value = on pipeAudioFlow.value = on
AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString()) AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString())
if (on) CarAudio.request() else CarAudio.release()
} }
fun setExperimentalFeaturesEnabled(on: Boolean) { fun setExperimentalFeaturesEnabled(on: Boolean) {
@@ -126,14 +126,14 @@ fun CarSettingsScreen() {
) { CarSettings.setAutoplay(it) } ) { CarSettings.setAutoplay(it) }
ChoiceRow( ChoiceRow(
"Media audio", "Car audio",
listOf(false to "Phone audio", true to "Car audio"), listOf(false to "Bluetooth", true to "USB"),
pipeAudio, pipeAudio,
) { CarSettings.setPipeAudio(it) } ) { CarSettings.setPipeAudio(it) }
Text( Text(
"Car audio sends the phone's sound to the car over USB. Android asks for permission " + "USB sends the phone's sound through Gearslip. Android asks to share your screen each " +
"when the car connects, and a few apps block it. Phone audio plays as the phone " + "drive, and a few apps block it. Bluetooth plays as the phone normally would, and " +
"normally would, for example over Bluetooth to the car.", "Android never asks.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -33,12 +34,14 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -55,6 +58,7 @@ import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.AppSettings import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.BuildConfig import app.seb3thehacker.gearslip.BuildConfig
import app.seb3thehacker.gearslip.CertProvider import app.seb3thehacker.gearslip.CertProvider
import app.seb3thehacker.gearslip.car.CarSettings
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -103,6 +107,7 @@ fun SettingsScreen(
Spacer(Modifier.height(0.dp)) Spacer(Modifier.height(0.dp))
AboutSection(onOpenWhatsNew, onReplayTutorial) AboutSection(onOpenWhatsNew, onReplayTutorial)
ConnectionSection() ConnectionSection()
CarAudioSection()
if (SHOW_RECOMMENDED_APPS) AppsSection(onOpenRecommendedApps) if (SHOW_RECOMMENDED_APPS) AppsSection(onOpenRecommendedApps)
CertificateSection() CertificateSection()
SettingsSection("Usage notes", header = USAGE_STATS_INTRO) { UsageStatsRows() } 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 @Composable
private fun AppsSection(onOpenRecommendedApps: () -> Unit) { private fun AppsSection(onOpenRecommendedApps: () -> Unit) {
SettingsSection("Apps") { SettingsSection("Apps") {