diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt index de0fbc9..63c3c34 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt @@ -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) } } 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 f807f4b..ee35562 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/BluetoothPairOverlay.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/BluetoothPairOverlay.kt @@ -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( 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 cd185ba..47f084d 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt @@ -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. 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 9d9592c..1be58f2 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt @@ -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(null) private val lastMediaFlow = MutableStateFlow(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 = bluetoothWarningFlow + /** Warn on the car when the phone's location is off; see LocationCheck. */ + val locationWarning: StateFlow = 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()) 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 a606e1d..c6248d4 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt @@ -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", diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/LocationCheck.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/LocationCheck.kt new file mode 100644 index 0000000..74bb711 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/LocationCheck.kt @@ -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 = _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 = _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 +}