Car maps keep their GPS with the phone locked once location is allowed all the time

A car that connected by itself with the phone locked left navigation apps without a fix
until Gearslip was opened (#99). Home now asks for Allow all the time while a car is
connected, and the location service starts as soon as it's granted. The weather no
longer reads the location when Gearslip is opened with no car connected.
This commit is contained in:
Seb3thehacker committed 2026-11-11 11:07:42 -08:00
1 parent ba86685935
commit 3860e978ac
6 files changed
+100 -1

No files matched your search

+5
View File
@@ -21,6 +21,11 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<!--
Lets LocationKeepAlive start with the phone locked, when a car connects by itself. Only
asked for from Home while a car is connected, and only used while one is (issue #99).
-->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- Keeps a wireless start going with the phone locked; see WirelessKeepAlive. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
@@ -11,6 +11,7 @@ import android.location.Geocoder
import android.location.Location
import android.location.LocationManager
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.SessionStatus
import java.net.HttpURLConnection
import java.net.URL
import java.util.Locale
@@ -66,8 +67,21 @@ object Weather {
private var placeCache: Pair<String, String>? = null // rounded "lat,lon" to place name
/** Set while the car preview is on the phone's screen, where the dashboard shows the weather too. */
@Volatile var previewOpen = false
/**
* Location is read only while a car is connected or the preview is open. Opening Gearslip on
* the phone used to warm the forecast too, which read the location with no car anywhere.
*/
private val mayLocate: Boolean
get() = previewOpen || SessionStatus.state.value.phase.let {
it == SessionStatus.Phase.CONNECTING || it == SessionStatus.Phase.PROJECTING
}
/** Runs off the main thread. Fresh data younger than [TTL_MS] is kept unless [force]. */
fun refresh(context: Context, force: Boolean = false) {
if (!mayLocate) return
val current = flow.value
if (!force && current is WeatherState.Ready && System.currentTimeMillis() - current.data.fetchedAt < TTL_MS) return
val app = context.applicationContext
@@ -46,7 +46,8 @@ class LocationKeepAlive : Service() {
}.onSuccess {
settle(State.ON)
}.onFailure {
GearslipLog.w("location keep-alive: could not start the foreground service - ${it.message}")
val background = if (allowedInBackground(this)) "allowed all the time" else "not allowed all the time"
GearslipLog.w("location keep-alive: could not start the foreground service ($background) - ${it.message}")
settle(State.FAILED)
stopSelf()
}
@@ -101,6 +102,21 @@ class LocationKeepAlive : Service() {
}
}
/**
* Starts the service again if a car still holds it but it was refused, as it is when a car
* connects with the phone locked and "Allow all the time" isn't granted. Called once the
* driver grants it, so the map gets its fix without reconnecting.
*/
fun retry(context: Context) {
main.post {
if (holders > 0 && state == State.FAILED) begin(context.applicationContext)
}
}
/** Whether Android lets this service start with the phone locked (issue #99). */
fun allowedInBackground(context: Context) =
context.checkSelfPermission(Manifest.permission.ACCESS_BACKGROUND_LOCATION) == PackageManager.PERMISSION_GRANTED
/** Lets go of one hold; the last one stops the service. */
fun release(context: Context) {
main.post {
@@ -72,6 +72,11 @@ fun CarPreviewScreen(onBack: () -> Unit) {
// The preview is something to watch, like a map on a dash mount, so the phone stays awake on
// it. Only here: the flag goes with this screen, and every other screen sleeps as usual.
val view = androidx.compose.ui.platform.LocalView.current
// Set during composition, before the car UI below warms the forecast.
remember { app.seb3thehacker.gearslip.car.Weather.previewOpen = true }
DisposableEffect(Unit) {
onDispose { app.seb3thehacker.gearslip.car.Weather.previewOpen = false }
}
DisposableEffect(view) {
view.keepScreenOn = true
onDispose { view.keepScreenOn = false }
@@ -112,6 +112,7 @@ fun HomeScreen(
Spacer(Modifier.height(16.dp))
AudioSwitch()
if (live) BluetoothMissingCard()
if (live) BackgroundLocationCard()
BluetoothAddressCard()
VoiceDownloadCard()
// The first drive that worked asks once to share it, right under the result.
@@ -239,6 +240,60 @@ private fun BluetoothMissingCard() {
}
}
/**
* Asks for "Allow all the time" location, shown only while a car is connected (issue #99).
* Without it, a car that connects with the phone locked leaves the car's map without GPS
* until Gearslip is opened. Once granted, the location service starts at once.
*/
@Composable
private fun BackgroundLocationCard() {
val context = LocalContext.current
var allowed by remember { mutableStateOf(app.seb3thehacker.gearslip.host.LocationKeepAlive.allowedInBackground(context)) }
var dismissed by remember { mutableStateOf(false) }
if (allowed || dismissed) return
fun recheck() {
allowed = app.seb3thehacker.gearslip.host.LocationKeepAlive.allowedInBackground(context)
if (allowed) app.seb3thehacker.gearslip.host.LocationKeepAlive.retry(context)
}
// On Android 11 and later this opens the app's location settings, where "Allow all the time" is.
val askBackground = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
) { recheck() }
// Android only offers all the time once the ordinary location permission is granted.
val askForeground = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions(),
) { result ->
if (result[android.Manifest.permission.ACCESS_FINE_LOCATION] == true) {
askBackground.launch(android.Manifest.permission.ACCESS_BACKGROUND_LOCATION)
}
}
val dark = gearslipDark()
val container = if (dark) Color(0xFF4A3B00) else Color(0xFFFFE08A)
val content = if (dark) Color(0xFFFFE08A) else Color(0xFF3A2E00)
Spacer(Modifier.height(16.dp))
Surface(color = container, contentColor = content, shape = MaterialTheme.shapes.extraLarge) {
Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.home_location_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Text(stringResource(R.string.home_location_body), style = MaterialTheme.typography.bodyMedium)
Row(Modifier.padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
colors = ButtonDefaults.buttonColors(containerColor = content, contentColor = container),
onClick = {
val fine = context.checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
if (fine) askBackground.launch(android.Manifest.permission.ACCESS_BACKGROUND_LOCATION)
else askForeground.launch(arrayOf(android.Manifest.permission.ACCESS_FINE_LOCATION, android.Manifest.permission.ACCESS_COARSE_LOCATION))
},
) { Text(stringResource(R.string.home_location_allow)) }
androidx.compose.material3.TextButton(
colors = ButtonDefaults.textButtonColors(contentColor = content),
onClick = { dismissed = true },
) { Text(stringResource(R.string.home_location_not_now)) }
}
}
}
}
/** While the voice models download, how far along they are; nothing otherwise. */
@Composable
private fun VoiceDownloadCard() {
+4
View File
@@ -438,6 +438,10 @@
<!-- -->
<string name="home_debug_build">%1$s · debug build</string>
<string name="home_bt_body">Music plays over Bluetooth, but your phone isn\'t connected to the car. Connect it here or from the car\'s Bluetooth menu.</string>
<string name="home_location_title">Keep the car\'s map on GPS</string>
<string name="home_location_body">With the phone locked, Android stops passing your location to the car\'s map. Choose Allow all the time, and Gearslip keeps it going. It reads your location in the background only while a car is connected.</string>
<string name="home_location_allow">Allow location</string>
<string name="home_location_not_now">Not now</string>
<string name="home_voice_downloading">Downloading voice, %1$d%%</string>
<!-- Car audio dialogs -->