bluetooth: offer to pair with the car on first connect, and pick Bluetooth or USB audio in setup

This commit is contained in:
Seb3thehacker committed 2026-10-13 14:55:11 -07:00
1 parent 5fe59d350c
commit f1fe94c487
8 files changed
+488 -3

No files matched your search

@@ -4,6 +4,7 @@ import android.os.Build
import app.seb3thehacker.gearslip.audio.AudioLink
import app.seb3thehacker.gearslip.audio.CarMic
import app.seb3thehacker.gearslip.media.CarMediaStatus
import app.seb3thehacker.gearslip.car.CarBluetooth
import app.seb3thehacker.gearslip.car.ClusterNav
import app.seb3thehacker.gearslip.car.DirectionsVoice
import app.seb3thehacker.gearslip.car.CarAssistant
@@ -162,6 +163,7 @@ class GearslipRunner(
guidanceLink = null
CarSensors.clear()
CarMic.current = null
CarBluetooth.onCarDisconnected()
mediaStatus?.stop()
mediaStatus = null
clusterNav?.stop()
@@ -209,6 +211,8 @@ class GearslipRunner(
mediaStatus?.onMessage(messageId, body)
} else if (frame.channel == guidanceLink?.channelId) {
guidanceLink?.onMessage(messageId, body)
} else if (frame.channel == CarBluetooth.channelId) {
CarBluetooth.onMessage(messageId, body)
} else if (frame.channel == CarMic.current?.channelId) {
CarMic.current?.onMessage(messageId, body)
} else if (frame.channel == audioLink?.channelId) {
@@ -465,6 +469,7 @@ class GearslipRunner(
startMicChannel(serviceDiscoveryResponse)
startMediaStatusChannel(serviceDiscoveryResponse)
startClusterNavChannel(serviceDiscoveryResponse)
startBluetooth(serviceDiscoveryResponse)
val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse, video.displayId)
if (input == null) {
@@ -565,6 +570,19 @@ class GearslipRunner(
openChannel(channel)
}
/** Bluetooth pairing, for calls: the channel only opens if the driver asks to pair. */
private fun startBluetooth(serviceDiscoveryResponse: ByteArray) {
val bluetooth = ServiceDiscovery.findBluetoothService(serviceDiscoveryResponse) ?: run {
log.i("no Bluetooth service - the car can't be paired from here")
return
}
CarBluetooth.onCarConnected(
bluetooth,
open = { openChannel(bluetooth.serviceId) },
send = { id, body -> send(id, body, encrypted = true, channel = bluetooth.serviceId) },
)
}
/** Turn-by-turn for the instrument cluster: the next turn, its road, and how far. */
private fun startClusterNavChannel(serviceDiscoveryResponse: ByteArray) {
val channel = ServiceDiscovery.findNavStatusChannel(serviceDiscoveryResponse) ?: run {
@@ -153,6 +153,28 @@ object ServiceDiscovery {
return null
}
/**
* The car's Bluetooth, from BluetoothService (Service field 6):
* BluetoothService { string car_address = 1; repeated BluetoothPairingMethod methods = 2 [packed]; }
* Null when the car offers no pairing over the link.
*/
class BluetoothService(val serviceId: Int, val carAddress: String, val pairingMethods: List<Int>)
fun findBluetoothService(response: ByteArray): BluetoothService? {
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel)
val id = Wire.varint(service, 1)?.toInt() ?: continue
val fields = Wire.fields(Wire.bytes(service, 6) ?: continue)
val address = Wire.bytes(fields, 1)?.let { String(it, Charsets.UTF_8) } ?: continue
// Packed as declared, though a car may send each method as its own varint.
val methods = fields.filter { it.number == 2 }.flatMap { f ->
f.bytes?.let(::packedVarints) ?: listOf(f.varint.toInt())
}
return BluetoothService(id, address, methods)
}
return null
}
const val STREAM_GUIDANCE = 1
const val STREAM_MEDIA = 3
const val CODEC_PCM = 1
@@ -0,0 +1,97 @@
package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
/**
* Asks once per car whether to pair it over Bluetooth, then waits while the phone shows its
* pairing dialog. See [CarBluetooth]. Unlike the call prompt, a tap outside the card does nothing:
* the driver picks Pair or Not now.
*/
@Composable
internal fun BluetoothPairOverlay() {
val prompt by CarBluetooth.prompt.collectAsState()
val bluetoothAudio = !CarSettings.pipeAudio.collectAsState().value
when (prompt) {
CarBluetooth.Prompt.NONE -> {}
CarBluetooth.Prompt.ASK -> PairCard(
title = "Pair with this car?",
body = (if (bluetoothAudio) "Calls and music play" else "Calls play") +
" through the car's speakers once your phone and the car are paired. Your phone " +
"then asks you to confirm a code.",
secondary = "Not now" to CarBluetooth::notNow,
primary = "Pair" to CarBluetooth::pair,
)
CarBluetooth.Prompt.PAIRING -> PairCard(
title = "Confirm on your phone",
body = "Check the code on your phone and tap Pair. The car may ask too.",
secondary = null,
primary = "Hide" to CarBluetooth::hide,
)
}
}
@Composable
private fun PairCard(title: String, body: String, secondary: Pair<String, () -> Unit>?, primary: Pair<String, () -> Unit>) {
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f)).clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = {},
),
contentAlignment = Alignment.Center,
) {
Surface(
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = Modifier.fillMaxWidth(0.85f).padding(16.dp),
) {
Column(
Modifier.padding(32.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(
body,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
secondary?.let { (label, onClick) ->
GsButton(onClick = onClick, tone = GsTone.Outline, modifier = Modifier.weight(1f).height(56.dp)) {
Text(label, style = MaterialTheme.typography.titleMedium)
}
}
GsButton(onClick = primary.second, modifier = Modifier.weight(1f).height(56.dp)) {
Text(primary.first, style = MaterialTheme.typography.titleMedium)
}
}
}
}
}
}
@@ -0,0 +1,252 @@
package app.seb3thehacker.gearslip.car
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Handler
import android.os.Looper
import androidx.car.app.CarToast
import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.Protobuf
import app.seb3thehacker.gearslip.ServiceDiscovery
import app.seb3thehacker.gearslip.Wire
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.security.MessageDigest
/**
* Pairs the phone with the car over Bluetooth the first time they meet, so calls (and music, when
* the driver chose Bluetooth audio) reach the car's speakers. Android Auto does this on its own;
* Gearslip asks first, on the car screen.
*
* The car names its Bluetooth address in its service list. When the driver taps Pair, Gearslip
* opens the car's Bluetooth channel and sends what Android Auto sends:
*
* -> CHANNEL_OPEN_REQUEST <- CHANNEL_OPEN_RESPONSE
* -> PairingRequest { phone_address, method }
* <- PairingResponse { status, already_paired }
*
* then starts pairing from the phone. Two things Android Auto has that Gearslip can't: the
* phone's own Bluetooth address (apps get a placeholder), and confirming the pairing code without
* asking. So a car may turn the request down, and the phone always shows its pairing dialog. The
* channel stays closed unless the driver taps Pair: every new channel is a risk to cars that work.
*/
object CarBluetooth {
enum class Prompt { NONE, ASK, PAIRING }
private val _prompt = MutableStateFlow(Prompt.NONE)
val prompt: StateFlow<Prompt> = _prompt
private val log = GearslipLog.tagged("bluetooth")
private val main = Handler(Looper.getMainLooper())
private var appContext: Context? = null
/** This session's car: its channel, and how to open and write to it. */
private class Car(
val service: ServiceDiscovery.BluetoothService,
val open: () -> Unit,
val send: (messageId: Int, body: ByteArray) -> Unit,
)
@Volatile private var car: Car? = null
@Volatile private var bondStarted = false
/** From the Pair tap until it succeeds or fails, whether or not the card still shows. */
@Volatile private var pairing = false
private var receiver: BroadcastReceiver? = null
/** The car's Bluetooth channel once the driver has asked to pair, -1 otherwise. */
val channelId: Int get() = car?.takeIf { pairing }?.service?.serviceId ?: -1
fun init(context: Context) {
appContext = context.applicationContext
}
/** Called once the car lists its services. Asks on the car screen only if there's pairing to do. */
fun onCarConnected(
service: ServiceDiscovery.BluetoothService,
open: () -> Unit,
send: (messageId: Int, body: ByteArray) -> Unit,
) {
car = Car(service, open, send)
bondStarted = false
log.i("the car offers Bluetooth pairing, methods=${service.pairingMethods}")
val device = device() ?: return
when {
device.bondState == BluetoothDevice.BOND_BONDED -> log.i("already paired with this car")
declined(service.carAddress) -> log.i("not paired, and the driver said not now for this car")
else -> _prompt.value = Prompt.ASK
}
}
fun onCarDisconnected() {
car = null
pairing = false
main.post {
stopWatching()
_prompt.value = Prompt.NONE
}
}
/** The driver tapped Pair. */
fun pair() {
val c = car ?: return
val device = device() ?: return
pairing = true
_prompt.value = Prompt.PAIRING
watchBonding(device)
c.open()
log.i("-> ChannelOpenRequest on channel ${c.service.serviceId}")
// If the car never answers on its channel, pair from the phone anyway.
main.postDelayed({ startBond("the car didn't answer the pairing request") }, CAR_REPLY_WAIT_MS)
main.postDelayed({
if (pairing) finish("Pairing didn't finish. Pair from the car's Bluetooth menu instead.")
}, GIVE_UP_MS)
}
/** The driver tapped Not now: this car isn't asked about again. */
fun notNow() {
car?.service?.carAddress?.let { address ->
val context = appContext ?: return@let
val list = AppSettings.getString(context, KEY_DECLINED, "").split(",").filter { it.isNotEmpty() }
AppSettings.putString(context, KEY_DECLINED, (list + hash(address)).distinct().joinToString(","))
}
_prompt.value = Prompt.NONE
}
/** Hides the pairing card. The phone's own dialog carries on. */
fun hide() {
_prompt.value = Prompt.NONE
}
fun onMessage(messageId: Int, body: ByteArray) {
val c = car ?: return
when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> {
val status = Protobuf.readInt32Field(body, 1) ?: 0
log.i("<- ChannelOpenResponse: status=$status")
if (status != 0) {
main.post { startBond("the car wouldn't open its Bluetooth channel") }
return
}
val method = c.service.pairingMethods.firstOrNull { it == METHOD_NUMERIC_COMPARISON }
?: c.service.pairingMethods.firstOrNull() ?: METHOD_NUMERIC_COMPARISON
c.send(
MSG_PAIRING_REQUEST,
Protobuf.stringField(1, phoneAddress()) + Protobuf.varintField(2, method.toLong()),
)
log.i("-> PairingRequest(method=$method)")
}
MSG_PAIRING_RESPONSE -> {
val fields = Wire.fields(body)
log.i("<- PairingResponse status=${Wire.varint(fields, 1)} already_paired=${Wire.varint(fields, 2) == 1L}")
// Some cars start pairing themselves once they answer; give them a moment first.
main.postDelayed({ startBond("the car answered") }, CAR_BOND_WAIT_MS)
}
// Carries the pairing code. Android shows it to the driver, so it stays out of the log.
MSG_AUTHENTICATION_DATA -> log.i("<- AuthenticationData (${body.size} bytes)")
else -> log.i("<- unhandled message id=$messageId (${body.size} bytes)")
}
}
@SuppressLint("MissingPermission")
private fun startBond(why: String) {
if (bondStarted || !pairing) return
val device = device() ?: return
if (device.bondState != BluetoothDevice.BOND_NONE) return
bondStarted = true
val started = runCatching { device.createBond() }.getOrDefault(false)
log.i("pairing from the phone ($why): ${if (started) "started" else "refused"}")
if (!started) finish("Pairing didn't start. Pair from the car's Bluetooth menu instead.")
}
private fun watchBonding(device: BluetoothDevice) {
val context = appContext ?: return
stopWatching()
val r = object : BroadcastReceiver() {
@Suppress("DEPRECATION")
override fun onReceive(context: Context, intent: Intent) {
val changed = intent.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE) ?: return
if (changed.address != device.address) return
val state = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.BOND_NONE)
val previous = intent.getIntExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, BluetoothDevice.BOND_NONE)
log.i("bond state $previous -> $state")
when {
state == BluetoothDevice.BOND_BONDING -> bondStarted = true
state == BluetoothDevice.BOND_BONDED -> finish("Paired. Calls now play through the car.")
previous == BluetoothDevice.BOND_BONDING -> finish("Pairing didn't finish. Pair from the car's Bluetooth menu instead.")
}
}
}
context.registerReceiver(r, IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED), Context.RECEIVER_EXPORTED)
receiver = r
}
private fun finish(message: String) {
pairing = false
stopWatching()
main.removeCallbacksAndMessages(null)
_prompt.value = Prompt.NONE
CarToasts.show(this, message, CarToast.LENGTH_LONG)
}
private fun stopWatching() {
val r = receiver ?: return
receiver = null
runCatching { appContext?.unregisterReceiver(r) }
}
/** The car as a Bluetooth device, or null (and logged) when Gearslip can't reach Bluetooth. */
private fun device(): BluetoothDevice? {
val context = appContext ?: return null
val address = car?.service?.carAddress ?: return null
if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
log.i("can't check pairing: the Bluetooth permission isn't granted")
return null
}
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter
if (adapter == null || !adapter.isEnabled) {
log.i("can't check pairing: Bluetooth is off")
return null
}
if (!BluetoothAdapter.checkBluetoothAddress(address)) {
log.w("the car sent an address that isn't one")
return null
}
return adapter.getRemoteDevice(address)
}
/** Apps get 02:00:00:00:00:00 here, not the real address. Sent anyway, as the field is required. */
@SuppressLint("MissingPermission", "HardwareIds")
private fun phoneAddress(): String =
runCatching { appContext?.getSystemService(BluetoothManager::class.java)?.adapter?.address }.getOrNull()
?: "02:00:00:00:00:00"
private fun declined(address: String): Boolean {
val context = appContext ?: return false
return hash(address) in AppSettings.getString(context, KEY_DECLINED, "").split(",")
}
/** Settings keep a short hash, not the car's address. */
private fun hash(address: String): String =
MessageDigest.getInstance("SHA-256").digest(address.uppercase().toByteArray())
.take(8).joinToString("") { "%02x".format(it) }
private const val KEY_DECLINED = "bluetooth_pair_declined"
private const val CAR_REPLY_WAIT_MS = 5_000L
private const val CAR_BOND_WAIT_MS = 3_000L
private const val GIVE_UP_MS = 90_000L
private const val METHOD_NUMERIC_COMPARISON = 2
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
private const val MSG_PAIRING_REQUEST = 32769
private const val MSG_PAIRING_RESPONSE = 32770
private const val MSG_AUTHENTICATION_DATA = 32771
}
@@ -66,6 +66,7 @@ object CarServices {
app.seb3thehacker.gearslip.call.CarCalls.start(appContext)
CarAssistant.init(appContext)
DirectionsVoice.init(appContext)
CarBluetooth.init(appContext)
CarKeys.init(appContext)
VoiceReply.init(appContext)
// Loaded now, while nothing's waiting on it, so the first reply isn't slower than the rest.
@@ -162,6 +162,10 @@ private const val KEY_SPEAK_DIRECTIONS = "car_speak_directions"
* 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.
*/
/** False until the driver picks USB or Bluetooth, in setup or in settings. */
val pipeAudioChosen: Boolean
get() = AppSettings.getString(app, KEY_PIPE_AUDIO, "") != ""
val audioChoiceAsked: Boolean
get() = AppSettings.getString(app, KEY_AUDIO_CHOICE_ASKED, "false") == "true"
@@ -210,7 +214,9 @@ private const val KEY_SPEAK_DIRECTIONS = "car_speak_directions"
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
playerTitleFlow.value = AppSettings.getString(app, KEY_PLAYER_TITLE, "false") == "true"
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true"
speakDirectionsFlow.value = AppSettings.getString(app, KEY_SPEAK_DIRECTIONS, "true") == "true"
// Until the driver sets it, on for USB audio and off for Bluetooth, where the map app's
// own voice already reaches the car.
speakDirectionsFlow.value = AppSettings.getString(app, KEY_SPEAK_DIRECTIONS, pipeAudioFlow.value.toString()) == "true"
nightFlow.value = runCatching {
NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name))
}.getOrDefault(NightMode.AUTO)
@@ -274,9 +280,11 @@ private const val KEY_SPEAK_DIRECTIONS = "car_speak_directions"
* back asks for it again (or waits for the car, if none is connected).
*/
fun setPipeAudio(on: Boolean) {
// Saved even when unchanged, so picking the default still counts as a choice.
AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString())
if (pipeAudioFlow.value == on) return
pipeAudioFlow.value = on
AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString())
if (AppSettings.getString(app, KEY_SPEAK_DIRECTIONS, "") == "") speakDirectionsFlow.value = on
if (on) CarAudio.request() else CarAudio.release()
}
@@ -249,6 +249,7 @@ fun CarUi() {
}
CarToastOverlay(Modifier.align(Alignment.BottomCenter))
CallPromptOverlay()
BluetoothPairOverlay()
PopupTimer()
// The dashboard is already listing them, so it needs no popup.
NotificationPopup(
@@ -73,6 +73,14 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.car.CarSettings
import androidx.compose.foundation.selection.selectable
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.RadioButton
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.sp
import app.seb3thehacker.gearslip.call.CarCalls
import app.seb3thehacker.gearslip.car.Prefetch
import app.seb3thehacker.gearslip.host.AndroidAuto
@@ -100,6 +108,8 @@ private class GuidePage(
val onAction: (() -> Unit)? = null,
/** Drawn under [explanation]. */
val content: (@Composable () -> Unit)? = null,
/** Only shown when the driver picked USB audio on the car audio page. */
val usbOnly: Boolean = false,
)
/** A plain Material icon, sized to fill the badge's icon box. */
@@ -213,6 +223,17 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
),
)
}
if (!granted(context, arrayOf(Manifest.permission.BLUETOOTH_CONNECT))) {
add(
GuidePage(
title = "Bluetooth.",
explanation = "The first time you plug in, Gearslip offers to pair your phone with the " +
"car, so calls play through its speakers.",
icon = glyph(Icons.Filled.Call),
permissions = arrayOf(Manifest.permission.BLUETOOTH_CONNECT),
),
)
}
if (!granted(context, arrayOf(Manifest.permission.POST_NOTIFICATIONS))) {
add(
@@ -270,6 +291,16 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
),
)
// --- Car audio: always asked, and it decides whether the adb pages below show ---------
add(
GuidePage(
title = "Car audio.",
explanation = "Pick how music reaches the car. Calls go over Bluetooth either way.",
icon = glyph(Icons.Filled.PlayArrow),
content = { AudioChoice() },
),
)
// --- Optional: each is skipped once its actual effect is already in place --------
if (!hasProjectMediaOp(context)) {
add(
@@ -279,6 +310,7 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
"Gearslip with your audio for good. Without it, you tap Start once each drive.",
icon = glyph(Icons.Filled.Build),
optional = true,
usbOnly = true,
command = "adb shell appops set ${BuildConfig.APPLICATION_ID} PROJECT_MEDIA allow",
),
)
@@ -292,6 +324,7 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
"drive and hide them again after.",
icon = glyph(Icons.Filled.Build),
optional = true,
usbOnly = true,
command = "adb shell pm grant ${BuildConfig.APPLICATION_ID} android.permission.WRITE_SECURE_SETTINGS",
),
)
@@ -327,6 +360,55 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
add(usageNotesPage(context))
}
/**
* Bluetooth or USB for the car's music, as two cards. Bluetooth comes first and is picked for a
* driver who hasn't chosen yet: it never asks for consent, and the map app's voice reaches the car.
*/
@Composable
private fun AudioChoice() {
val usb by CarSettings.pipeAudio.collectAsState()
LaunchedEffect(Unit) { if (!CarSettings.pipeAudioChosen) CarSettings.setPipeAudio(false) }
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
AudioCard(
"Bluetooth", "Recommended",
"No prompt each drive, and your map app's voice reaches the car.",
selected = !usb,
) { CarSettings.setPipeAudio(false) }
AudioCard(
"USB", null,
"Clearer sound with no delay. Android asks to share audio once each drive.",
selected = usb,
) { CarSettings.setPipeAudio(true) }
}
}
@Composable
private fun AudioCard(title: String, tag: String?, body: String, selected: Boolean, onClick: () -> Unit) {
val c = introColors()
val shape = RoundedCornerShape(24.dp)
Row(
Modifier
.fillMaxWidth()
.clip(shape)
.background(if (selected) c.primaryC else Color.Transparent)
.border(BorderStroke(2.dp, if (selected) c.primary else c.outline), shape)
.selectable(selected, role = Role.RadioButton, onClick = onClick)
.padding(16.dp),
verticalAlignment = Alignment.Top,
) {
RadioButton(selected = selected, onClick = null)
Column(Modifier.padding(start = 12.dp)) {
Text(title, color = if (selected) c.onPrimaryC else c.onSurface, style = TextStyle(fontSize = 18.sp, fontWeight = FontWeight.Bold))
tag?.let { Text(it, color = c.primary, style = TextStyle(fontSize = 13.sp, fontWeight = FontWeight.SemiBold)) }
Text(
body, color = if (selected) c.onPrimaryC else c.onVariant,
style = TextStyle(fontSize = 15.sp, lineHeight = 21.sp),
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
/**
* The first-run guide, one page at a time: permissions, then removing Android Auto if it is still installed, then the optional extras. Nothing
* here can be verified as done except a permission grant, so every other page is a "Next" a
@@ -335,7 +417,11 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
@Composable
fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onlyUsageNotes: Boolean = false) {
val context = LocalContext.current
val pages = remember { if (onlyUsageNotes) listOf(usageNotesPage(context)) else pages(context, onRequestCallScreening) }
val allPages = remember { if (onlyUsageNotes) listOf(usageNotesPage(context)) else pages(context, onRequestCallScreening) }
// The USB-only pages all come after the car audio page, so dropping them never moves the
// page the driver is on.
val usbAudio by CarSettings.pipeAudio.collectAsState()
val pages = remember(usbAudio) { allPages.filter { usbAudio || !it.usbOnly } }
var index by remember { mutableIntStateOf(0) }
// Only ours to handle once there is a previous page to return to; at the first page, the