diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt index f9ca071..57ebf92 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt @@ -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 { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt index 529d99f..0ecdc04 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt @@ -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) + + 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 diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/BluetoothPairOverlay.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/BluetoothPairOverlay.kt new file mode 100644 index 0000000..6e50ea1 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/BluetoothPairOverlay.kt @@ -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 Unit>?, primary: Pair 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) + } + } + } + } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarBluetooth.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarBluetooth.kt new file mode 100644 index 0000000..8f1b188 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarBluetooth.kt @@ -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 + + 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.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 +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt index 587c4ea..4d186be 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt @@ -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. diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt index 43c505c..3c15ab6 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt @@ -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() } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt index 99649b7..cd63733 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt @@ -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( diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt index 10ce412..5b51bdc 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt @@ -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 Unit): List Unit): List Unit): List Unit): List 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 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