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.media.CarMediaStatus
import app.seb3thehacker.gearslip.car.BluetoothAudioCheck
import app.seb3thehacker.gearslip.car.LocationCheck
import app.seb3thehacker.gearslip.car.CarBluetooth
import app.seb3thehacker.gearslip.car.ClusterNav
import app.seb3thehacker.gearslip.car.DirectionsVoice
@@ -167,6 +168,7 @@ class GearslipRunner(
CarMic.current = null
CarBluetooth.onCarDisconnected()
BluetoothAudioCheck.onCarDisconnected()
LocationCheck.onCarDisconnected()
mediaStatus?.stop()
mediaStatus = null
clusterNav?.stop()
@@ -1016,6 +1018,7 @@ class GearslipRunner(
projection.onSurfaceReady(surface, width, height, config.density)
SessionStatus.projecting()
BluetoothAudioCheck.onProjecting()
LocationCheck.onProjecting()
}
}.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
* 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.
*/
@Composable
@@ -41,12 +42,20 @@ internal fun BluetoothPairOverlay() {
val missing by BluetoothAudioCheck.missing.collectAsState()
val dismissed by BluetoothAudioCheck.carDismissed.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(
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,
) 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(
@@ -68,6 +68,7 @@ object CarServices {
DirectionsVoice.init(appContext)
CarBluetooth.init(appContext)
BluetoothAudioCheck.init(appContext)
LocationCheck.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.
@@ -30,6 +30,7 @@ object CarSettings {
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_LOCATION_WARNING = "car_location_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"
@@ -62,6 +63,7 @@ private const val KEY_BLUETOOTH_WARNING = "car_bluetooth_warning"
private val pipeAudioFlow = MutableStateFlow(true)
private val speakDirectionsFlow = MutableStateFlow(true)
private val bluetoothWarningFlow = MutableStateFlow(true)
private val locationWarningFlow = MutableStateFlow(true)
private val lastNavFlow = MutableStateFlow<String?>(null)
private val lastMediaFlow = MutableStateFlow<String?>(null)
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. */
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
* 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.
speakDirectionsFlow.value = AppSettings.getString(app, KEY_SPEAK_DIRECTIONS, pipeAudioFlow.value.toString()) == "true"
bluetoothWarningFlow.value = AppSettings.getString(app, KEY_BLUETOOTH_WARNING, "true") == "true"
locationWarningFlow.value = AppSettings.getString(app, KEY_LOCATION_WARNING, "true") == "true"
nightFlow.value = runCatching {
NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name))
}.getOrDefault(NightMode.AUTO)
@@ -304,6 +310,11 @@ private const val KEY_BLUETOOTH_WARNING = "car_bluetooth_warning"
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) {
experimentalFeaturesFlow.value = on
AppSettings.putString(app, KEY_EXPERIMENTAL_FEATURES, on.toString())
@@ -55,6 +55,7 @@ fun CarSettingsScreen() {
val pipeAudio by CarSettings.pipeAudio.collectAsState()
val speakDirections by CarSettings.speakDirections.collectAsState()
val bluetoothWarning by CarSettings.bluetoothWarning.collectAsState()
val locationWarning by CarSettings.locationWarning.collectAsState()
val autoplay by CarSettings.autoplay.collectAsState()
val playerTitle by CarSettings.playerTitle.collectAsState()
val theme by CarSettings.appTheme.collectAsState()
@@ -145,6 +146,11 @@ fun CarSettingsScreen() {
bluetoothWarning,
) { CarSettings.setBluetoothWarning(it) }
}
ChoiceRow(
"Warn when location is off",
listOf(false to "Off", true to "On"),
locationWarning,
) { CarSettings.setLocationWarning(it) }
ChoiceRow(
"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
}