car: say when the phone's location is off, ten seconds into a drive, with Don't show again and a setting to turn the warning off

This commit is contained in:
Seb3thehacker committed 2026-10-14 15:45:10 -07:00
1 parent 9b7734bec8
commit 815a75ad6d
6 files changed
+124 -1

No files matched your search

@@ -5,6 +5,7 @@ import app.seb3thehacker.gearslip.audio.AudioLink
import app.seb3thehacker.gearslip.audio.CarMic import app.seb3thehacker.gearslip.audio.CarMic
import app.seb3thehacker.gearslip.media.CarMediaStatus import app.seb3thehacker.gearslip.media.CarMediaStatus
import app.seb3thehacker.gearslip.car.BluetoothAudioCheck import app.seb3thehacker.gearslip.car.BluetoothAudioCheck
import app.seb3thehacker.gearslip.car.LocationCheck
import app.seb3thehacker.gearslip.car.CarBluetooth import app.seb3thehacker.gearslip.car.CarBluetooth
import app.seb3thehacker.gearslip.car.ClusterNav import app.seb3thehacker.gearslip.car.ClusterNav
import app.seb3thehacker.gearslip.car.DirectionsVoice import app.seb3thehacker.gearslip.car.DirectionsVoice
@@ -167,6 +168,7 @@ class GearslipRunner(
CarMic.current = null CarMic.current = null
CarBluetooth.onCarDisconnected() CarBluetooth.onCarDisconnected()
BluetoothAudioCheck.onCarDisconnected() BluetoothAudioCheck.onCarDisconnected()
LocationCheck.onCarDisconnected()
mediaStatus?.stop() mediaStatus?.stop()
mediaStatus = null mediaStatus = null
clusterNav?.stop() clusterNav?.stop()
@@ -1016,6 +1018,7 @@ class GearslipRunner(
projection.onSurfaceReady(surface, width, height, config.density) projection.onSurfaceReady(surface, width, height, config.density)
SessionStatus.projecting() SessionStatus.projecting()
BluetoothAudioCheck.onProjecting() BluetoothAudioCheck.onProjecting()
LocationCheck.onProjecting()
} }
}.onFailure { e -> log.e("encoder start failed", e) } }.onFailure { e -> log.e("encoder start failed", e) }
} }
@@ -29,7 +29,8 @@ import androidx.compose.ui.unit.dp
/** /**
* Asks once per car whether to pair it over Bluetooth, then waits while the phone shows its * Asks once per car whether to pair it over Bluetooth, then waits while the phone shows its
* pairing dialog. See [CarBluetooth]. With no pairing to do, says when Bluetooth audio isn't * 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: * connected (see [BluetoothAudioCheck]), or else when the phone's location is off (see
* [LocationCheck]). Unlike the call prompt, a tap outside the card does nothing:
* the driver picks Pair or Not now. * the driver picks Pair or Not now.
*/ */
@Composable @Composable
@@ -41,12 +42,20 @@ internal fun BluetoothPairOverlay() {
val missing by BluetoothAudioCheck.missing.collectAsState() val missing by BluetoothAudioCheck.missing.collectAsState()
val dismissed by BluetoothAudioCheck.carDismissed.collectAsState() val dismissed by BluetoothAudioCheck.carDismissed.collectAsState()
val warn by CarSettings.bluetoothWarning.collectAsState() val warn by CarSettings.bluetoothWarning.collectAsState()
val locationOff by LocationCheck.off.collectAsState()
val locationDismissed by LocationCheck.carDismissed.collectAsState()
if (missing && bluetoothAudio && warn && !dismissed) PairCard( if (missing && bluetoothAudio && warn && !dismissed) PairCard(
title = "Bluetooth isn't connected", title = "Bluetooth isn't connected",
body = "Music plays over Bluetooth, but your phone isn't connected to the car. " + 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.", "Connect it from the car's Bluetooth menu or your phone's.",
secondary = "Don't show again" to BluetoothAudioCheck::neverAgain, secondary = "Don't show again" to BluetoothAudioCheck::neverAgain,
primary = "OK" to BluetoothAudioCheck::dismissOnCar, primary = "OK" to BluetoothAudioCheck::dismissOnCar,
) else if (locationOff && !locationDismissed) PairCard(
title = "Location is off",
body = "Map apps can't show where you are or give directions. Turn on location " +
"in your phone's quick settings when you're parked.",
secondary = "Don't show again" to LocationCheck::neverAgain,
primary = "OK" to LocationCheck::dismissOnCar,
) )
} }
CarBluetooth.Prompt.ASK -> PairCard( CarBluetooth.Prompt.ASK -> PairCard(
@@ -68,6 +68,7 @@ object CarServices {
DirectionsVoice.init(appContext) DirectionsVoice.init(appContext)
CarBluetooth.init(appContext) CarBluetooth.init(appContext)
BluetoothAudioCheck.init(appContext) BluetoothAudioCheck.init(appContext)
LocationCheck.init(appContext)
CarKeys.init(appContext) CarKeys.init(appContext)
VoiceReply.init(appContext) VoiceReply.init(appContext)
// Loaded now, while nothing's waiting on it, so the first reply isn't slower than the rest. // Loaded now, while nothing's waiting on it, so the first reply isn't slower than the rest.
@@ -30,6 +30,7 @@ object CarSettings {
private const val KEY_PIPE_AUDIO = "car_pipe_audio" private const val KEY_PIPE_AUDIO = "car_pipe_audio"
private const val KEY_SPEAK_DIRECTIONS = "car_speak_directions" private const val KEY_SPEAK_DIRECTIONS = "car_speak_directions"
private const val KEY_BLUETOOTH_WARNING = "car_bluetooth_warning" private const val KEY_BLUETOOTH_WARNING = "car_bluetooth_warning"
private const val KEY_LOCATION_WARNING = "car_location_warning"
private const val KEY_AUDIO_CHOICE_ASKED = "car_audio_choice_asked" 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_NAV = "car_last_nav"
private const val KEY_LAST_MEDIA = "car_last_media" private const val KEY_LAST_MEDIA = "car_last_media"
@@ -62,6 +63,7 @@ private const val KEY_BLUETOOTH_WARNING = "car_bluetooth_warning"
private val pipeAudioFlow = MutableStateFlow(true) private val pipeAudioFlow = MutableStateFlow(true)
private val speakDirectionsFlow = MutableStateFlow(true) private val speakDirectionsFlow = MutableStateFlow(true)
private val bluetoothWarningFlow = MutableStateFlow(true) private val bluetoothWarningFlow = MutableStateFlow(true)
private val locationWarningFlow = MutableStateFlow(true)
private val lastNavFlow = MutableStateFlow<String?>(null) private val lastNavFlow = MutableStateFlow<String?>(null)
private val lastMediaFlow = MutableStateFlow<String?>(null) private val lastMediaFlow = MutableStateFlow<String?>(null)
private val autoplayFlow = MutableStateFlow(false) private val autoplayFlow = MutableStateFlow(false)
@@ -163,6 +165,9 @@ private const val KEY_BLUETOOTH_WARNING = "car_bluetooth_warning"
/** With Bluetooth audio, warn when the phone isn't connected to the car; see BluetoothAudioCheck. */ /** With Bluetooth audio, warn when the phone isn't connected to the car; see BluetoothAudioCheck. */
val bluetoothWarning: StateFlow<Boolean> = bluetoothWarningFlow val bluetoothWarning: StateFlow<Boolean> = bluetoothWarningFlow
/** Warn on the car when the phone's location is off; see LocationCheck. */
val locationWarning: StateFlow<Boolean> = locationWarningFlow
/** /**
* Whether the driver has already been offered Bluetooth after turning down the capture * 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. * dialog. Offered once only: someone who chose to keep the USB route shouldn't be nagged.
@@ -223,6 +228,7 @@ private const val KEY_BLUETOOTH_WARNING = "car_bluetooth_warning"
// own voice already reaches the car. // own voice already reaches the car.
speakDirectionsFlow.value = AppSettings.getString(app, KEY_SPEAK_DIRECTIONS, pipeAudioFlow.value.toString()) == "true" speakDirectionsFlow.value = AppSettings.getString(app, KEY_SPEAK_DIRECTIONS, pipeAudioFlow.value.toString()) == "true"
bluetoothWarningFlow.value = AppSettings.getString(app, KEY_BLUETOOTH_WARNING, "true") == "true" bluetoothWarningFlow.value = AppSettings.getString(app, KEY_BLUETOOTH_WARNING, "true") == "true"
locationWarningFlow.value = AppSettings.getString(app, KEY_LOCATION_WARNING, "true") == "true"
nightFlow.value = runCatching { nightFlow.value = runCatching {
NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name)) NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name))
}.getOrDefault(NightMode.AUTO) }.getOrDefault(NightMode.AUTO)
@@ -304,6 +310,11 @@ private const val KEY_BLUETOOTH_WARNING = "car_bluetooth_warning"
AppSettings.putString(app, KEY_BLUETOOTH_WARNING, on.toString()) AppSettings.putString(app, KEY_BLUETOOTH_WARNING, on.toString())
} }
fun setLocationWarning(on: Boolean) {
locationWarningFlow.value = on
AppSettings.putString(app, KEY_LOCATION_WARNING, on.toString())
}
fun setExperimentalFeaturesEnabled(on: Boolean) { fun setExperimentalFeaturesEnabled(on: Boolean) {
experimentalFeaturesFlow.value = on experimentalFeaturesFlow.value = on
AppSettings.putString(app, KEY_EXPERIMENTAL_FEATURES, on.toString()) AppSettings.putString(app, KEY_EXPERIMENTAL_FEATURES, on.toString())
@@ -55,6 +55,7 @@ fun CarSettingsScreen() {
val pipeAudio by CarSettings.pipeAudio.collectAsState() val pipeAudio by CarSettings.pipeAudio.collectAsState()
val speakDirections by CarSettings.speakDirections.collectAsState() val speakDirections by CarSettings.speakDirections.collectAsState()
val bluetoothWarning by CarSettings.bluetoothWarning.collectAsState() val bluetoothWarning by CarSettings.bluetoothWarning.collectAsState()
val locationWarning by CarSettings.locationWarning.collectAsState()
val autoplay by CarSettings.autoplay.collectAsState() val autoplay by CarSettings.autoplay.collectAsState()
val playerTitle by CarSettings.playerTitle.collectAsState() val playerTitle by CarSettings.playerTitle.collectAsState()
val theme by CarSettings.appTheme.collectAsState() val theme by CarSettings.appTheme.collectAsState()
@@ -145,6 +146,11 @@ fun CarSettingsScreen() {
bluetoothWarning, bluetoothWarning,
) { CarSettings.setBluetoothWarning(it) } ) { CarSettings.setBluetoothWarning(it) }
} }
ChoiceRow(
"Warn when location is off",
listOf(false to "Off", true to "On"),
locationWarning,
) { CarSettings.setLocationWarning(it) }
ChoiceRow( ChoiceRow(
"Spoken directions", "Spoken directions",
@@ -0,0 +1,93 @@
package app.seb3thehacker.gearslip.car
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.location.LocationManager
import android.os.Handler
import android.os.Looper
import app.seb3thehacker.gearslip.GearslipLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Map apps can't find the car with the phone's location turned off. Ten seconds after the car
* shows Gearslip, this checks, and the car says so until location comes on or the drive ends.
*/
object LocationCheck {
private val _off = MutableStateFlow(false)
/** True while the phone's location is turned off during a drive. */
val off: StateFlow<Boolean> = _off
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("location")
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()
_off.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.setLocationWarning(false)
log.i("location warning turned off")
_carDismissed.value = true
_off.value = false
}
private fun start() {
if (!armed) return
check()
// Turning location on clears the warning, and turning it off again brings it back.
val context = appContext ?: return
val r = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) = check()
}
context.registerReceiver(r, IntentFilter(LocationManager.MODE_CHANGED_ACTION), Context.RECEIVER_EXPORTED)
receiver = r
}
private fun check() {
if (!armed) return
val manager = appContext?.getSystemService(LocationManager::class.java) ?: return
val off = CarSettings.locationWarning.value && !manager.isLocationEnabled
if (_off.value == off) return
_off.value = off
log.i(if (off) "the phone's location is off" else "the phone's location is on")
}
private fun stopWatching() {
val r = receiver ?: return
receiver = null
runCatching { appContext?.unregisterReceiver(r) }
}
private const val WAIT_MS = 10_000L
}