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.
This commit is contained in:
Seb3thehacker committed 2026-11-04 23:53:06 -08:00
1 parent 12baaca79a
commit e9aa6debb6
5 files changed
+209 -2

No files matched your search

@@ -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<String?>(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<String?> = _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
@@ -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<String?>(null) }
var typing by rememberSaveable { mutableStateOf<String?>(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
@@ -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
@@ -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",
+9
View File
@@ -875,6 +875,15 @@
<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="bt_address_card_title">Help %1$s know this phone</string>
<string name="bt_address_card_body">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.</string>
<string name="bt_address_open">Open About phone</string>
<string name="bt_address_not_now">Not now</string>
<string name="bt_address_use_title">Use this Bluetooth address?</string>
<string name="bt_address_use_body">Gearslip passes it to your car, so the car knows this is the phone it\'s paired with.</string>
<string name="bt_address_type">Type it instead</string>
<string name="bt_address_your_car">your car</string>
<string name="bt_address_car_note">Finish one setup step on your phone when you\'re parked.</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>