diff --git a/gearslip/src/main/AndroidManifest.xml b/gearslip/src/main/AndroidManifest.xml index a557c31..9c6b5d6 100644 --- a/gearslip/src/main/AndroidManifest.xml +++ b/gearslip/src/main/AndroidManifest.xml @@ -21,6 +21,11 @@ + + diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/Weather.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/Weather.kt index ecfba71..d47ace5 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/Weather.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/Weather.kt @@ -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? = 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 diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/LocationKeepAlive.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/LocationKeepAlive.kt index 2932865..0584d85 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/LocationKeepAlive.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/LocationKeepAlive.kt @@ -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 { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt index dd17df4..2b5a2f6 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt @@ -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 } 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 3b80771..a35639e 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt @@ -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() { diff --git a/gearslip/src/main/res/values/strings.xml b/gearslip/src/main/res/values/strings.xml index e7bbec1..4881a88 100644 --- a/gearslip/src/main/res/values/strings.xml +++ b/gearslip/src/main/res/values/strings.xml @@ -438,6 +438,10 @@ %1$s ยท debug build Music plays over Bluetooth, but your phone isn\'t connected to the car. Connect it here or from the car\'s Bluetooth menu. + Keep the car\'s map on GPS + 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. + Allow location + Not now Downloading voice, %1$d%%