diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt index d06bb5c..de0fbc9 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt @@ -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) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/BluetoothAudioCheck.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/BluetoothAudioCheck.kt new file mode 100644 index 0000000..8d84f41 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/BluetoothAudioCheck.kt @@ -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 = _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 = _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 +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/BluetoothPairOverlay.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/BluetoothPairOverlay.kt index 6e50ea1..f807f4b 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/BluetoothPairOverlay.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/BluetoothPairOverlay.kt @@ -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") + 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 8f1b188..f211656 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarBluetooth.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarBluetooth.kt @@ -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 diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt index 4365baf..f88a6b0 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt @@ -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. diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt index 3c15ab6..9d9592c 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt @@ -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(null) private val lastMediaFlow = MutableStateFlow(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 = speakDirectionsFlow + /** With Bluetooth audio, warn when the phone isn't connected to the car; see BluetoothAudioCheck. */ + val bluetoothWarning: StateFlow = 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()) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt index e14ec7a..a606e1d 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt @@ -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", 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 f235914..3b2f486 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt @@ -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() {