mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
bluetooth: offer to pair with the car on first connect, and pick Bluetooth or USB audio in setup
This commit is contained in:
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.AudioLink
|
||||||
import app.seb3thehacker.gearslip.audio.CarMic
|
import app.seb3thehacker.gearslip.audio.CarMic
|
||||||
import app.seb3thehacker.gearslip.media.CarMediaStatus
|
import app.seb3thehacker.gearslip.media.CarMediaStatus
|
||||||
|
import app.seb3thehacker.gearslip.car.CarBluetooth
|
||||||
import app.seb3thehacker.gearslip.car.ClusterNav
|
import app.seb3thehacker.gearslip.car.ClusterNav
|
||||||
import app.seb3thehacker.gearslip.car.DirectionsVoice
|
import app.seb3thehacker.gearslip.car.DirectionsVoice
|
||||||
import app.seb3thehacker.gearslip.car.CarAssistant
|
import app.seb3thehacker.gearslip.car.CarAssistant
|
||||||
@@ -162,6 +163,7 @@ class GearslipRunner(
|
|||||||
guidanceLink = null
|
guidanceLink = null
|
||||||
CarSensors.clear()
|
CarSensors.clear()
|
||||||
CarMic.current = null
|
CarMic.current = null
|
||||||
|
CarBluetooth.onCarDisconnected()
|
||||||
mediaStatus?.stop()
|
mediaStatus?.stop()
|
||||||
mediaStatus = null
|
mediaStatus = null
|
||||||
clusterNav?.stop()
|
clusterNav?.stop()
|
||||||
@@ -209,6 +211,8 @@ class GearslipRunner(
|
|||||||
mediaStatus?.onMessage(messageId, body)
|
mediaStatus?.onMessage(messageId, body)
|
||||||
} else if (frame.channel == guidanceLink?.channelId) {
|
} else if (frame.channel == guidanceLink?.channelId) {
|
||||||
guidanceLink?.onMessage(messageId, body)
|
guidanceLink?.onMessage(messageId, body)
|
||||||
|
} else if (frame.channel == CarBluetooth.channelId) {
|
||||||
|
CarBluetooth.onMessage(messageId, body)
|
||||||
} else if (frame.channel == CarMic.current?.channelId) {
|
} else if (frame.channel == CarMic.current?.channelId) {
|
||||||
CarMic.current?.onMessage(messageId, body)
|
CarMic.current?.onMessage(messageId, body)
|
||||||
} else if (frame.channel == audioLink?.channelId) {
|
} else if (frame.channel == audioLink?.channelId) {
|
||||||
@@ -465,6 +469,7 @@ class GearslipRunner(
|
|||||||
startMicChannel(serviceDiscoveryResponse)
|
startMicChannel(serviceDiscoveryResponse)
|
||||||
startMediaStatusChannel(serviceDiscoveryResponse)
|
startMediaStatusChannel(serviceDiscoveryResponse)
|
||||||
startClusterNavChannel(serviceDiscoveryResponse)
|
startClusterNavChannel(serviceDiscoveryResponse)
|
||||||
|
startBluetooth(serviceDiscoveryResponse)
|
||||||
|
|
||||||
val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse, video.displayId)
|
val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse, video.displayId)
|
||||||
if (input == null) {
|
if (input == null) {
|
||||||
@@ -565,6 +570,19 @@ class GearslipRunner(
|
|||||||
openChannel(channel)
|
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. */
|
/** Turn-by-turn for the instrument cluster: the next turn, its road, and how far. */
|
||||||
private fun startClusterNavChannel(serviceDiscoveryResponse: ByteArray) {
|
private fun startClusterNavChannel(serviceDiscoveryResponse: ByteArray) {
|
||||||
val channel = ServiceDiscovery.findNavStatusChannel(serviceDiscoveryResponse) ?: run {
|
val channel = ServiceDiscovery.findNavStatusChannel(serviceDiscoveryResponse) ?: run {
|
||||||
|
|||||||
@@ -153,6 +153,28 @@ object ServiceDiscovery {
|
|||||||
return null
|
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_GUIDANCE = 1
|
||||||
const val STREAM_MEDIA = 3
|
const val STREAM_MEDIA = 3
|
||||||
const val CODEC_PCM = 1
|
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)
|
app.seb3thehacker.gearslip.call.CarCalls.start(appContext)
|
||||||
CarAssistant.init(appContext)
|
CarAssistant.init(appContext)
|
||||||
DirectionsVoice.init(appContext)
|
DirectionsVoice.init(appContext)
|
||||||
|
CarBluetooth.init(appContext)
|
||||||
CarKeys.init(appContext)
|
CarKeys.init(appContext)
|
||||||
VoiceReply.init(appContext)
|
VoiceReply.init(appContext)
|
||||||
// Loaded now, while nothing's waiting on it, so the first reply isn't slower than the rest.
|
// 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
|
* 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.
|
* 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
|
val audioChoiceAsked: Boolean
|
||||||
get() = AppSettings.getString(app, KEY_AUDIO_CHOICE_ASKED, "false") == "true"
|
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"
|
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
|
||||||
playerTitleFlow.value = AppSettings.getString(app, KEY_PLAYER_TITLE, "false") == "true"
|
playerTitleFlow.value = AppSettings.getString(app, KEY_PLAYER_TITLE, "false") == "true"
|
||||||
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "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 {
|
nightFlow.value = runCatching {
|
||||||
NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name))
|
NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name))
|
||||||
}.getOrDefault(NightMode.AUTO)
|
}.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).
|
* back asks for it again (or waits for the car, if none is connected).
|
||||||
*/
|
*/
|
||||||
fun setPipeAudio(on: Boolean) {
|
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
|
if (pipeAudioFlow.value == on) return
|
||||||
pipeAudioFlow.value = on
|
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()
|
if (on) CarAudio.request() else CarAudio.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ fun CarUi() {
|
|||||||
}
|
}
|
||||||
CarToastOverlay(Modifier.align(Alignment.BottomCenter))
|
CarToastOverlay(Modifier.align(Alignment.BottomCenter))
|
||||||
CallPromptOverlay()
|
CallPromptOverlay()
|
||||||
|
BluetoothPairOverlay()
|
||||||
PopupTimer()
|
PopupTimer()
|
||||||
// The dashboard is already listing them, so it needs no popup.
|
// The dashboard is already listing them, so it needs no popup.
|
||||||
NotificationPopup(
|
NotificationPopup(
|
||||||
|
|||||||
@@ -73,6 +73,14 @@ import androidx.compose.ui.text.AnnotatedString
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import app.seb3thehacker.gearslip.AppSettings
|
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.call.CarCalls
|
||||||
import app.seb3thehacker.gearslip.car.Prefetch
|
import app.seb3thehacker.gearslip.car.Prefetch
|
||||||
import app.seb3thehacker.gearslip.host.AndroidAuto
|
import app.seb3thehacker.gearslip.host.AndroidAuto
|
||||||
@@ -100,6 +108,8 @@ private class GuidePage(
|
|||||||
val onAction: (() -> Unit)? = null,
|
val onAction: (() -> Unit)? = null,
|
||||||
/** Drawn under [explanation]. */
|
/** Drawn under [explanation]. */
|
||||||
val content: (@Composable () -> Unit)? = null,
|
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. */
|
/** 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))) {
|
if (!granted(context, arrayOf(Manifest.permission.POST_NOTIFICATIONS))) {
|
||||||
add(
|
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 --------
|
// --- Optional: each is skipped once its actual effect is already in place --------
|
||||||
if (!hasProjectMediaOp(context)) {
|
if (!hasProjectMediaOp(context)) {
|
||||||
add(
|
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.",
|
"Gearslip with your audio for good. Without it, you tap Start once each drive.",
|
||||||
icon = glyph(Icons.Filled.Build),
|
icon = glyph(Icons.Filled.Build),
|
||||||
optional = true,
|
optional = true,
|
||||||
|
usbOnly = true,
|
||||||
command = "adb shell appops set ${BuildConfig.APPLICATION_ID} PROJECT_MEDIA allow",
|
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.",
|
"drive and hide them again after.",
|
||||||
icon = glyph(Icons.Filled.Build),
|
icon = glyph(Icons.Filled.Build),
|
||||||
optional = true,
|
optional = true,
|
||||||
|
usbOnly = true,
|
||||||
command = "adb shell pm grant ${BuildConfig.APPLICATION_ID} android.permission.WRITE_SECURE_SETTINGS",
|
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))
|
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
|
* 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
|
* 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
|
@Composable
|
||||||
fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onlyUsageNotes: Boolean = false) {
|
fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onlyUsageNotes: Boolean = false) {
|
||||||
val context = LocalContext.current
|
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) }
|
var index by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
// Only ours to handle once there is a previous page to return to; at the first page, the
|
// Only ours to handle once there is a previous page to return to; at the first page, the
|
||||||
|
|||||||
Reference in new issue
Block a user