diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarBluetooth.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarBluetooth.kt index 14b2e5f..5d78d5a 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarBluetooth.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarBluetooth.kt @@ -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 diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt index 3601398..ad02511 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt @@ -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() { diff --git a/gearslip/src/main/res/values/strings.xml b/gearslip/src/main/res/values/strings.xml index 8712577..6c67911 100644 --- a/gearslip/src/main/res/values/strings.xml +++ b/gearslip/src/main/res/values/strings.xml @@ -866,6 +866,10 @@ The phone is charging, but no car has answered. Check that USB-C port control, under Security & privacy, allows data and isn\'t set to Charging-only. 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. 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. + Phone\'s Bluetooth address + Not set. Only for a car that drops Bluetooth when Gearslip connects. + 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. + That isn\'t a Bluetooth address. It looks like 12:34:56:78:9A:BC. Use as launcher For a head unit or a tablet on the dash. Gearslip\'s car screen becomes this device\'s home screen. Gearslip can be this device\'s home screen. Pick it under Default home app.