car audio: with Bluetooth audio chosen, warn on the car and on Home when the phone isn't connected to the car 20 seconds in

This commit is contained in:
Seb3thehacker committed 2026-10-13 18:09:04 -07:00
1 parent a84d36f17a
commit 88e1885b2d
8 files changed
+221 -2

No files matched your search

@@ -4,6 +4,7 @@ import android.os.Build
import app.seb3thehacker.gearslip.audio.AudioLink
import app.seb3thehacker.gearslip.audio.CarMic
import app.seb3thehacker.gearslip.media.CarMediaStatus
import app.seb3thehacker.gearslip.car.BluetoothAudioCheck
import app.seb3thehacker.gearslip.car.CarBluetooth
import app.seb3thehacker.gearslip.car.ClusterNav
import app.seb3thehacker.gearslip.car.DirectionsVoice
@@ -165,6 +166,7 @@ class GearslipRunner(
CarSensors.clear()
CarMic.current = null
CarBluetooth.onCarDisconnected()
BluetoothAudioCheck.onCarDisconnected()
mediaStatus?.stop()
mediaStatus = null
clusterNav?.stop()
@@ -1013,6 +1015,7 @@ class GearslipRunner(
if (projection != null && surface != null) {
projection.onSurfaceReady(surface, width, height, config.density)
SessionStatus.projecting()
BluetoothAudioCheck.onProjecting()
}
}.onFailure { e -> log.e("encoder start failed", e) }
}
@@ -0,0 +1,146 @@
package app.seb3thehacker.gearslip.car
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothA2dp
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Handler
import android.os.Looper
import app.seb3thehacker.gearslip.GearslipLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* With Bluetooth audio chosen, music only reaches the car if the phone is connected to it over
* Bluetooth. Twenty seconds after the car shows Gearslip, long enough for a car to connect on its
* own, this checks. If the phone isn't connected, the car and the phone's Home screen both say so,
* until it connects or the drive ends.
*
* When the car named its Bluetooth address, only that device counts. Otherwise any audio
* connection does, since there's no telling which device is the car.
*/
object BluetoothAudioCheck {
private val _missing = MutableStateFlow(false)
/** True while Bluetooth audio is chosen and the phone isn't connected to the car for it. */
val missing: StateFlow<Boolean> = _missing
private val _carDismissed = MutableStateFlow(false)
/** The driver tapped OK on the car's card; it stays away for the rest of this drive. */
val carDismissed: StateFlow<Boolean> = _carDismissed
private val log = GearslipLog.tagged("bluetooth")
private val main = Handler(Looper.getMainLooper())
private var appContext: Context? = null
private var armed = false
private var receiver: BroadcastReceiver? = null
fun init(context: Context) {
appContext = context.applicationContext
}
/** The car shows Gearslip. Called again whenever the video restarts, so it arms once per drive. */
fun onProjecting() = main.post {
if (armed) return@post
armed = true
main.postDelayed(::start, WAIT_MS)
}
fun onCarDisconnected() = main.post {
armed = false
main.removeCallbacksAndMessages(null)
stopWatching()
_missing.value = false
_carDismissed.value = false
}
fun dismissOnCar() {
_carDismissed.value = true
}
/** "Don't show again" on the car: turns the warning off in Settings, for every car. */
fun neverAgain() {
CarSettings.setBluetoothWarning(false)
log.i("Bluetooth audio warning turned off")
_carDismissed.value = true
_missing.value = false
}
private fun start() {
if (!armed) return
check()
// Connecting later clears the warning, and losing the connection brings it back.
val context = appContext ?: return
val r = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
main.postDelayed(::check, SETTLE_MS)
}
}
context.registerReceiver(
r,
IntentFilter(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED).apply {
addAction(BluetoothAdapter.ACTION_STATE_CHANGED)
},
Context.RECEIVER_EXPORTED,
)
receiver = r
}
@SuppressLint("MissingPermission")
private fun check() {
if (!armed) return
val context = appContext ?: return
if (CarSettings.pipeAudio.value) {
set(false, "USB audio is on")
return
}
if (!CarSettings.bluetoothWarning.value) {
set(false, "the warning is turned off")
return
}
if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
log.i("can't check the audio connection: the Bluetooth permission isn't granted")
return
}
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter ?: return
if (!adapter.isEnabled) {
set(true, "Bluetooth is off")
return
}
val car = CarBluetooth.carAddress?.uppercase()
adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
val connected = runCatching { proxy.connectedDevices }.getOrDefault(emptyList())
adapter.closeProfileProxy(profile, proxy)
val ok = if (car != null) connected.any { it.address.uppercase() == car } else connected.isNotEmpty()
main.post {
set(!ok, if (ok) "connected to the car" else "not connected to the car")
}
}
override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.A2DP)
}
private fun set(missing: Boolean, why: String) {
if (!armed || _missing.value == missing) return
_missing.value = missing
log.i("Bluetooth audio: $why")
}
private fun stopWatching() {
val r = receiver ?: return
receiver = null
runCatching { appContext?.unregisterReceiver(r) }
}
private const val WAIT_MS = 20_000L
private const val SETTLE_MS = 1_000L
}
@@ -28,7 +28,8 @@ import androidx.compose.ui.unit.dp
/**
* Asks once per car whether to pair it over Bluetooth, then waits while the phone shows its
* pairing dialog. See [CarBluetooth]. Unlike the call prompt, a tap outside the card does nothing:
* pairing dialog. See [CarBluetooth]. With no pairing to do, says when Bluetooth audio isn't
* connected; see [BluetoothAudioCheck]. Unlike the call prompt, a tap outside the card does nothing:
* the driver picks Pair or Not now.
*/
@Composable
@@ -36,7 +37,18 @@ internal fun BluetoothPairOverlay() {
val prompt by CarBluetooth.prompt.collectAsState()
val bluetoothAudio = !CarSettings.pipeAudio.collectAsState().value
when (prompt) {
CarBluetooth.Prompt.NONE -> {}
CarBluetooth.Prompt.NONE -> {
val missing by BluetoothAudioCheck.missing.collectAsState()
val dismissed by BluetoothAudioCheck.carDismissed.collectAsState()
val warn by CarSettings.bluetoothWarning.collectAsState()
if (missing && bluetoothAudio && warn && !dismissed) PairCard(
title = "Bluetooth isn't connected",
body = "Music plays over Bluetooth, but your phone isn't connected to the car. " +
"Connect it from the car's Bluetooth menu or your phone's.",
secondary = "Don't show again" to BluetoothAudioCheck::neverAgain,
primary = "OK" to BluetoothAudioCheck::dismissOnCar,
)
}
CarBluetooth.Prompt.ASK -> PairCard(
title = "Pair with this car?",
body = (if (bluetoothAudio) "Calls and music play" else "Calls play") +
@@ -63,6 +63,9 @@ object CarBluetooth {
@Volatile private var pairing = 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
@@ -67,6 +67,7 @@ object CarServices {
CarAssistant.init(appContext)
DirectionsVoice.init(appContext)
CarBluetooth.init(appContext)
BluetoothAudioCheck.init(appContext)
CarKeys.init(appContext)
VoiceReply.init(appContext)
// Loaded now, while nothing's waiting on it, so the first reply isn't slower than the rest.
@@ -29,6 +29,7 @@ object CarSettings {
private const val KEY_NIGHT = "car_night_mode"
private const val KEY_PIPE_AUDIO = "car_pipe_audio"
private const val KEY_SPEAK_DIRECTIONS = "car_speak_directions"
private const val KEY_BLUETOOTH_WARNING = "car_bluetooth_warning"
private const val KEY_AUDIO_CHOICE_ASKED = "car_audio_choice_asked"
private const val KEY_LAST_NAV = "car_last_nav"
private const val KEY_LAST_MEDIA = "car_last_media"
@@ -60,6 +61,7 @@ private const val KEY_SPEAK_DIRECTIONS = "car_speak_directions"
private val nightFlow = MutableStateFlow(NightMode.AUTO)
private val pipeAudioFlow = MutableStateFlow(true)
private val speakDirectionsFlow = MutableStateFlow(true)
private val bluetoothWarningFlow = MutableStateFlow(true)
private val lastNavFlow = MutableStateFlow<String?>(null)
private val lastMediaFlow = MutableStateFlow<String?>(null)
private val autoplayFlow = MutableStateFlow(false)
@@ -158,6 +160,9 @@ private const val KEY_SPEAK_DIRECTIONS = "car_speak_directions"
/** Gearslip reads the map app's turns aloud on the car's directions channel; see DirectionsVoice. */
val speakDirections: StateFlow<Boolean> = speakDirectionsFlow
/** With Bluetooth audio, warn when the phone isn't connected to the car; see BluetoothAudioCheck. */
val bluetoothWarning: StateFlow<Boolean> = bluetoothWarningFlow
/**
* Whether the driver has already been offered Bluetooth after turning down the capture
* dialog. Offered once only: someone who chose to keep the USB route shouldn't be nagged.
@@ -217,6 +222,7 @@ private const val KEY_SPEAK_DIRECTIONS = "car_speak_directions"
// Until the driver sets it, on for USB audio and off for Bluetooth, where the map app's
// own voice already reaches the car.
speakDirectionsFlow.value = AppSettings.getString(app, KEY_SPEAK_DIRECTIONS, pipeAudioFlow.value.toString()) == "true"
bluetoothWarningFlow.value = AppSettings.getString(app, KEY_BLUETOOTH_WARNING, "true") == "true"
nightFlow.value = runCatching {
NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name))
}.getOrDefault(NightMode.AUTO)
@@ -293,6 +299,11 @@ private const val KEY_SPEAK_DIRECTIONS = "car_speak_directions"
AppSettings.putString(app, KEY_SPEAK_DIRECTIONS, on.toString())
}
fun setBluetoothWarning(on: Boolean) {
bluetoothWarningFlow.value = on
AppSettings.putString(app, KEY_BLUETOOTH_WARNING, on.toString())
}
fun setExperimentalFeaturesEnabled(on: Boolean) {
experimentalFeaturesFlow.value = on
AppSettings.putString(app, KEY_EXPERIMENTAL_FEATURES, on.toString())
@@ -54,6 +54,7 @@ fun CarSettingsScreen() {
val whatsNew by CarSettings.whatsNewAfterUpdates.collectAsState()
val pipeAudio by CarSettings.pipeAudio.collectAsState()
val speakDirections by CarSettings.speakDirections.collectAsState()
val bluetoothWarning by CarSettings.bluetoothWarning.collectAsState()
val autoplay by CarSettings.autoplay.collectAsState()
val playerTitle by CarSettings.playerTitle.collectAsState()
val theme by CarSettings.appTheme.collectAsState()
@@ -137,6 +138,13 @@ fun CarSettingsScreen() {
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (!pipeAudio) {
ChoiceRow(
"Warn when Bluetooth isn't connected",
listOf(false to "Off", true to "On"),
bluetoothWarning,
) { CarSettings.setBluetoothWarning(it) }
}
ChoiceRow(
"Spoken directions",
@@ -16,6 +16,10 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import android.content.Context
import app.seb3thehacker.gearslip.car.CarSettings
import app.seb3thehacker.gearslip.car.BluetoothAudioCheck
import android.provider.Settings
import android.content.Intent
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
@@ -104,6 +108,7 @@ fun HomeScreen(
)
Spacer(Modifier.height(16.dp))
StatusCard(status)
if (live) BluetoothMissingCard()
VoiceDownloadCard()
// The first drive that worked asks once to share it, right under the result.
val car = workedCar
@@ -149,6 +154,36 @@ fun HomeScreen(
}
}
/** Bluetooth audio is chosen, but the phone isn't connected to the car. See [BluetoothAudioCheck]. */
@Composable
private fun BluetoothMissingCard() {
val missing by BluetoothAudioCheck.missing.collectAsStateWithLifecycle()
val usb by CarSettings.pipeAudio.collectAsStateWithLifecycle()
val warn by CarSettings.bluetoothWarning.collectAsStateWithLifecycle()
if (!missing || usb || !warn) return
val context = LocalContext.current
val scheme = MaterialTheme.colorScheme
Spacer(Modifier.height(16.dp))
Surface(color = scheme.errorContainer, contentColor = scheme.onErrorContainer, shape = MaterialTheme.shapes.extraLarge) {
Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Bluetooth isn't connected", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Text(
"Music plays over Bluetooth, but your phone isn't connected to the car. Connect it " +
"here or from the car's Bluetooth menu.",
style = MaterialTheme.typography.bodyMedium,
)
Button(
onClick = {
runCatching {
context.startActivity(Intent(Settings.ACTION_BLUETOOTH_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
},
modifier = Modifier.padding(top = 4.dp),
) { Text("Bluetooth settings") }
}
}
}
/** While the voice models download, how far along they are; nothing otherwise. */
@Composable
private fun VoiceDownloadCard() {