From e9aa6debb60f47ac79f5ad5326894c60874af84c Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Wed, 4 Nov 2026 23:53:06 -0800 Subject: [PATCH] A paired car that doesn't know the phone's address gets it asked for on the phone The Bolt in #18 stayed connected only once Gearslip passed it the phone's Bluetooth address, which was hidden in Settings. After a paired car connects without it, Home asks once per car and opens About phone. Coming back, Gearslip offers the address it finds on the clipboard. The car screen says once to finish a step on the phone when parked. --- .../gearslip/car/CarBluetooth.kt | 47 +++++- .../gearslip/ui/BluetoothAddressCard.kt | 142 ++++++++++++++++++ .../seb3thehacker/gearslip/ui/HomeScreen.kt | 1 + .../gearslip/ui/SettingsScreen.kt | 12 +- gearslip/src/main/res/values/strings.xml | 9 ++ 5 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/ui/BluetoothAddressCard.kt 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 5748435..daacc54 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarBluetooth.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarBluetooth.kt @@ -80,6 +80,43 @@ object CarBluetooth { fun init(context: Context) { appContext = context.applicationContext + _addressWanted.value = AppSettings.getString(context, KEY_ADDRESS_ASK, "").ifEmpty { null } + } + + private val _addressWanted = MutableStateFlow(null) + + /** + * A car paired over Bluetooth connected while Gearslip didn't know the phone's address: the + * car's name, for the phone's home screen to ask for it. Null when there's nothing to ask. + * Issue #18: a 2021 Bolt hung up 25 seconds in until it was told which phone this is. + */ + val addressWanted: StateFlow = _addressWanted + + /** Debug builds: shows the phone's card as if a car had just asked. */ + fun askForAddressNow(context: Context) { + AppSettings.putString(context, KEY_ADDRESS_ASK, "Test car") + _addressWanted.value = "Test car" + } + + /** The phone's card was answered or turned away: it doesn't come back for this car. */ + fun addressAsked(context: Context) { + AppSettings.putString(context, KEY_ADDRESS_ASK, "") + _addressWanted.value = null + } + + /** Once per car: it waits on the phone, and the car screen says so once. */ + @SuppressLint("MissingPermission") + private fun askForAddress(device: BluetoothDevice, carAddress: String) { + val context = appContext ?: return + val asked = AppSettings.getString(context, KEY_ADDRESS_ASKED, "").split(",").filter { it.isNotEmpty() } + if (hash(carAddress) in asked) return + AppSettings.putString(context, KEY_ADDRESS_ASKED, (asked + hash(carAddress)).joinToString(",")) + val name = runCatching { device.alias ?: device.name }.getOrNull()?.takeIf { it.isNotBlank() } + ?: context.getString(R.string.bt_address_your_car) + AppSettings.putString(context, KEY_ADDRESS_ASK, name) + _addressWanted.value = name + log.i("paired, but the phone's address isn't known: asking on the phone") + main.post { CarToasts.show(this, context.getString(R.string.bt_address_car_note), CarToast.LENGTH_LONG) } } /** Called once the car lists its services. Asks on the car screen only if there's pairing to do. */ @@ -99,7 +136,10 @@ object CarBluetooth { announcing = true open() } - device.bondState == BluetoothDevice.BOND_BONDED -> log.i("already paired with this car") + device.bondState == BluetoothDevice.BOND_BONDED -> { + log.i("already paired with this car") + askForAddress(device, service.carAddress) + } declined(service.carAddress) -> log.i("not paired, and the driver said not now for this car") else -> _prompt.value = Prompt.ASK } @@ -284,6 +324,7 @@ object CarBluetooth { val tidy = address.trim().uppercase().replace('-', ':') if (tidy.isNotEmpty() && !BluetoothAdapter.checkBluetoothAddress(tidy)) return false AppSettings.putString(context, KEY_PHONE_ADDRESS, tidy) + if (tidy.isNotEmpty()) addressAsked(context) return true } @@ -298,6 +339,10 @@ object CarBluetooth { .take(8).joinToString("") { "%02x".format(it) } private const val KEY_DECLINED = "bluetooth_pair_declined" + /** The car's name while the phone's card asks for the address; empty when it doesn't. */ + private const val KEY_ADDRESS_ASK = "bluetooth_address_ask" + /** Hashes of the cars already asked about, so each asks once. */ + private const val KEY_ADDRESS_ASKED = "bluetooth_address_asked" 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 diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/BluetoothAddressCard.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/BluetoothAddressCard.kt new file mode 100644 index 0000000..48ea852 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/BluetoothAddressCard.kt @@ -0,0 +1,142 @@ +package app.seb3thehacker.gearslip.ui + +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.provider.Settings +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import app.seb3thehacker.gearslip.R +import app.seb3thehacker.gearslip.car.CarBluetooth +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Asks for the phone's Bluetooth address on Home, once per car, after a car paired over + * Bluetooth connected without it. Android keeps the address from apps, so the card opens About + * phone for the driver to copy it, and when they come back offers what's on the clipboard. + */ +@Composable +internal fun BluetoothAddressCard(modifier: Modifier = Modifier) { + val context = LocalContext.current + val car by CarBluetooth.addressWanted.collectAsState() + // Off to About phone; on the way back, the clipboard is looked at once. + var waiting by rememberSaveable { mutableStateOf(false) } + var found by rememberSaveable { mutableStateOf(null) } + var typing by rememberSaveable { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + if (!waiting) return@LifecycleEventEffect + waiting = false + // Android hands an app the clipboard only once its window has focus, a moment after resume. + scope.launch { + delay(CLIPBOARD_WAIT_MS) + val copied = copiedAddress(context) + if (copied != null) found = copied else typing = "" + } + } + + found?.let { address -> + AlertDialog( + onDismissRequest = { found = null }, + title = { Text(stringResource(R.string.bt_address_use_title)) }, + text = { + Column { + Text(address, style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(bottom = 12.dp)) + Text(stringResource(R.string.bt_address_use_body)) + } + }, + confirmButton = { + TextButton(onClick = { CarBluetooth.setTypedAddress(context, address); found = null }) { Text(stringResource(R.string.save)) } + }, + dismissButton = { TextButton(onClick = { typing = address; found = null }) { Text(stringResource(R.string.bt_address_type)) } }, + ) + } + typing?.let { initial -> + AddressDialog(initial, onDismiss = { typing = null }) { typed -> + CarBluetooth.setTypedAddress(context, typed).also { if (it) typing = null } + } + } + + val name = car ?: return + if (CarBluetooth.typedAddress(context) != null) return + val scheme = MaterialTheme.colorScheme + Column(modifier) { + Spacer(Modifier.height(16.dp)) + Surface( + color = scheme.secondaryContainer, + contentColor = scheme.onSecondaryContainer, + shape = MaterialTheme.shapes.extraLarge, + ) { + Column(Modifier.fillMaxWidth().padding(20.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Info, contentDescription = null) + Text( + stringResource(R.string.bt_address_card_title, name), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 12.dp), + ) + } + Text( + stringResource(R.string.bt_address_card_body), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 8.dp), + ) + Row( + Modifier.fillMaxWidth().padding(top = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + TextButton(onClick = { CarBluetooth.addressAsked(context) }) { Text(stringResource(R.string.bt_address_not_now)) } + Button(onClick = { + waiting = true + runCatching { + context.startActivity(Intent(Settings.ACTION_DEVICE_INFO_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + }.onFailure { waiting = false; typing = "" } + }) { Text(stringResource(R.string.bt_address_open)) } + } + } + } + } +} + +/** A Bluetooth address on the clipboard, tidied up; null when there's none. */ +private fun copiedAddress(context: Context): String? { + val clip = runCatching { context.getSystemService(ClipboardManager::class.java)?.primaryClip }.getOrNull() ?: return null + if (clip.itemCount == 0) return null + val text = clip.getItemAt(0).coerceToText(context)?.toString() ?: return null + return ADDRESS.find(text)?.value?.uppercase()?.replace('-', ':') +} + +private val ADDRESS = Regex("""[0-9A-Fa-f]{2}([:-][0-9A-Fa-f]{2}){5}""") +private const val CLIPBOARD_WAIT_MS = 400L diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt index 90a682e..bd4bb8c 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt @@ -108,6 +108,7 @@ fun HomeScreen( Spacer(Modifier.height(16.dp)) StatusCard(status) if (live) BluetoothMissingCard() + BluetoothAddressCard() VoiceDownloadCard() // The first drive that worked asks once to share it, right under the result. val car = workedCar 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 ad02511..896bdc2 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt @@ -275,7 +275,7 @@ private fun ConnectionSection() { /** 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) { +internal fun AddressDialog(initial: String, onDismiss: () -> Unit, onSave: (String) -> Boolean) { var text by remember { mutableStateOf(initial) } var invalid by remember { mutableStateOf(false) } AlertDialog( @@ -309,6 +309,16 @@ private fun DebugSection() { icon = Icons.AutoMirrored.Filled.Send, ) SettingsDivider() + SettingsRow( + "Ask for the Bluetooth address", + Modifier.clickable { + CarBluetooth.setTypedAddress(context, "") + CarBluetooth.askForAddressNow(context) + }, + subtitle = "Clears the saved address and shows Home's card", + icon = Icons.Filled.Info, + ) + SettingsDivider() val badge by CarSettings.debugBadge.collectAsState() SettingsRow( "Debug badge on the car screen", diff --git a/gearslip/src/main/res/values/strings.xml b/gearslip/src/main/res/values/strings.xml index ab23643..ea7b6fa 100644 --- a/gearslip/src/main/res/values/strings.xml +++ b/gearslip/src/main/res/values/strings.xml @@ -875,6 +875,15 @@ 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. + Help %1$s know this phone + Some cars stay connected only once they know the phone\'s Bluetooth address. Android keeps it from apps, so copy it from About phone. Press and hold the Bluetooth address there, tap Copy, and come back. + Open About phone + Not now + Use this Bluetooth address? + Gearslip passes it to your car, so the car knows this is the phone it\'s paired with. + Type it instead + your car + Finish one setup step on your phone when you\'re parked. 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.