mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 00:12:24 +01:00
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:
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() {
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
Reference in new issue
Block a user