The car can learn which paired phone is on the cable

A VW MIB2 drops an already paired phone's Bluetooth when Gearslip
connects over USB, because nothing tells it the two are the same phone
(#28). Android Auto says so on the car's Bluetooth channel with the
phone's address, which Android hides from apps. Settings now takes the
address typed in by hand; with it, Gearslip opens that channel on every
connection to a paired car and sends the address. Without it, nothing
changes.
This commit is contained in:
Seb3thehacker committed 2026-11-04 13:54:02 -08:00
1 parent fed735b5a2
commit bcd9a87174
3 files changed
+91 -4

No files matched your search

@@ -40,6 +40,11 @@ import java.security.MessageDigest
* 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.
*
* The one exception is a driver who typed the phone's real address into Settings. A VW MIB2 drops
* an already paired phone's Bluetooth when it connects over USB, because nothing tells it the two
* are the same phone (issue #28). With the address, Gearslip opens the channel on every connection
* and sends the same request, which is how Android Auto says so.
*/
object CarBluetooth {
@@ -63,13 +68,15 @@ object CarBluetooth {
@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
/** Already paired: the channel is open only to tell the car which phone this is. */
@Volatile private var announcing = false
private var receiver: BroadcastReceiver? = null
/** The car's Bluetooth address, when it named one in its service list. */
val carAddress: String? get() = car?.service?.carAddress
/** The car's Bluetooth channel once the driver has asked to pair, -1 otherwise. */
val channelId: Int get() = car?.takeIf { pairing }?.service?.serviceId ?: -1
val channelId: Int get() = car?.takeIf { pairing || announcing }?.service?.serviceId ?: -1
fun init(context: Context) {
appContext = context.applicationContext
@@ -83,9 +90,15 @@ object CarBluetooth {
) {
car = Car(service, open, send)
bondStarted = false
announcing = false
log.i("the car offers Bluetooth pairing, methods=${service.pairingMethods}")
val device = device() ?: return
when {
device.bondState == BluetoothDevice.BOND_BONDED && typedAddress() != null -> {
log.i("already paired with this car - telling it which phone this is")
announcing = true
open()
}
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
@@ -95,6 +108,7 @@ object CarBluetooth {
fun onCarDisconnected() {
car = null
pairing = false
announcing = false
main.post {
stopWatching()
_prompt.value = Prompt.NONE
@@ -139,7 +153,8 @@ object CarBluetooth {
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") }
if (announcing) log.i("the car wouldn't open its Bluetooth channel")
else main.post { startBond("the car wouldn't open its Bluetooth channel") }
return
}
val method = c.service.pairingMethods.firstOrNull { it == METHOD_NUMERIC_COMPARISON }
@@ -153,6 +168,7 @@ object CarBluetooth {
MSG_PAIRING_RESPONSE -> {
val fields = Wire.fields(body)
log.i("<- PairingResponse status=${Wire.varint(fields, 1)} already_paired=${Wire.varint(fields, 2) == 1L}")
if (!pairing) return
// Some cars start pairing themselves once they answer; give them a moment first.
main.postDelayed({ startBond("the car answered") }, CAR_BOND_WAIT_MS)
}
@@ -229,12 +245,30 @@ object CarBluetooth {
return adapter.getRemoteDevice(address)
}
/** Apps get 02:00:00:00:00:00 here, not the real address. Sent anyway, as the field is required. */
/**
* Apps get 02:00:00:00:00:00 here, not the real address, so the one the driver typed in wins.
* Sent either way, as the field is required. Never logged: it identifies the phone.
*/
@SuppressLint("MissingPermission", "HardwareIds")
private fun phoneAddress(): String =
runCatching { appContext?.getSystemService(BluetoothManager::class.java)?.adapter?.address }.getOrNull()
typedAddress()
?: runCatching { appContext?.getSystemService(BluetoothManager::class.java)?.adapter?.address }.getOrNull()
?: "02:00:00:00:00:00"
private fun typedAddress(): String? = appContext?.let { typedAddress(it) }
/** The phone's own address as the driver typed it in Settings, or null. */
fun typedAddress(context: Context): String? =
AppSettings.getString(context, KEY_PHONE_ADDRESS, "").takeIf { BluetoothAdapter.checkBluetoothAddress(it) }
/** Saves [address] after tidying it up; false when it isn't one. Empty clears it. */
fun setTypedAddress(context: Context, address: String): Boolean {
val tidy = address.trim().uppercase().replace('-', ':')
if (tidy.isNotEmpty() && !BluetoothAdapter.checkBluetoothAddress(tidy)) return false
AppSettings.putString(context, KEY_PHONE_ADDRESS, tidy)
return true
}
private fun declined(address: String): Boolean {
val context = appContext ?: return false
return hash(address) in AppSettings.getString(context, KEY_DECLINED, "").split(",")
@@ -246,6 +280,7 @@ object CarBluetooth {
.take(8).joinToString("") { "%02x".format(it) }
private const val KEY_DECLINED = "bluetooth_pair_declined"
private const val KEY_PHONE_ADDRESS = "phone_bluetooth_address"
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
@@ -20,6 +20,8 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.KeyboardCapitalization
import app.seb3thehacker.gearslip.car.CarBluetooth
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -246,9 +248,55 @@ private fun ConnectionSection() {
icon = Icons.Filled.Home,
trailing = { Switch(checked = launcher, onCheckedChange = null) },
)
var address by remember { mutableStateOf(CarBluetooth.typedAddress(context)) }
var editing by remember { mutableStateOf(false) }
SettingsRow(
stringResource(R.string.settings_bt_address),
Modifier.clickable { editing = true },
subtitle = address ?: stringResource(R.string.settings_bt_address_off),
icon = Icons.Filled.Info,
)
if (editing) {
AddressDialog(
initial = address.orEmpty(),
onDismiss = { editing = false },
onSave = { typed ->
CarBluetooth.setTypedAddress(context, typed).also { saved ->
if (saved) {
address = CarBluetooth.typedAddress(context)
editing = false
}
}
},
)
}
}
}
/** The phone's Bluetooth address, typed in by hand: Android doesn't let apps read it. */
@Composable
private fun AddressDialog(initial: String, onDismiss: () -> Unit, onSave: (String) -> Boolean) {
var text by remember { mutableStateOf(initial) }
var invalid by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.settings_bt_address)) },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it; invalid = false },
singleLine = true,
placeholder = { Text("12:34:56:78:9A:BC") },
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Characters, autoCorrectEnabled = false),
isError = invalid,
supportingText = { Text(stringResource(if (invalid) R.string.settings_bt_address_invalid else R.string.settings_bt_address_hint)) },
)
},
confirmButton = { TextButton(onClick = { invalid = !onSave(text) }) { Text(stringResource(R.string.save)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
)
}
/** Tools for testing a debug build; release builds never show this section. */
@Composable
private fun DebugSection() {
+4
View File
@@ -866,6 +866,10 @@
<string name="status_no_car_graphene">The phone is charging, but no car has answered. Check that USB-C port control, under Security &amp; privacy, allows data and isn\'t set to Charging-only.</string>
<string name="status_stuck_cable">The car hasn\'t shown Gearslip yet. Unplug the cable, wait a few seconds, and plug it back in. If it keeps happening, try another cable or USB port, and share the log in an issue.</string>
<string name="status_stuck_wireless">The car hasn\'t shown Gearslip yet. Turn the car off and on again, or connect with a cable. If it keeps happening, share the log in an issue.</string>
<string name="settings_bt_address">Phone\'s Bluetooth address</string>
<string name="settings_bt_address_off">Not set. Only for a car that drops Bluetooth when Gearslip connects.</string>
<string name="settings_bt_address_hint">Copy it from Android\'s Settings, under About phone. Gearslip passes it to the car, so the car knows the phone on the cable is the one it\'s paired with.</string>
<string name="settings_bt_address_invalid">That isn\'t a Bluetooth address. It looks like 12:34:56:78:9A:BC.</string>
<string name="settings_use_as_launcher">Use as launcher</string>
<string name="settings_use_as_launcher_off">For a head unit or a tablet on the dash. Gearslip\'s car screen becomes this device\'s home screen.</string>
<string name="settings_use_as_launcher_on">Gearslip can be this device\'s home screen. Pick it under Default home app.</string>