Google Maps and Waze turns in the nav bar and on the cluster

Neither runs on the car screen, but while one guides on the phone, Gearslip reads
its turn notification and shows the arrow, distance and road in the nav bar, and
sends the turn to the instrument cluster.
This commit is contained in:
Seb3thehacker committed 2026-11-03 12:27:07 -08:00
1 parent 1faf418798
commit cc78020eda
4 files changed
+184 -9

No files matched your search

@@ -75,6 +75,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.notify.PhoneNavigation
import app.seb3thehacker.gearslip.car.theme.GsColors
import app.seb3thehacker.gearslip.car.theme.withTintedSurfaces
import app.seb3thehacker.gearslip.car.theme.GsIconBox
@@ -197,12 +198,13 @@ internal fun CarNavBar(navigator: CarNavigator) {
// Next turn first, off the map only; the player shares the space when it fits,
// and gives way to the turn when it doesn't.
// A call takes the player's place until it ends.
val turn = rememberNextTurn().takeIf { navigator.current != CarScreen.Home }
// A turn from the phone has no map in the car to show it, so it shows on Home too.
val turn = rememberNextTurn()?.takeIf { it.fromPhone || navigator.current != CarScreen.Home }
val call = CarCalls.state.collectAsState().value as? CallUiState.Active
BoxWithConstraints(Modifier.weight(1f).padding(horizontal = 16.dp), contentAlignment = Alignment.Center) {
val roomForBoth = maxWidth >= TURN_AND_PLAYER_MIN_WIDTH
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
if (call == null || roomForBoth) turn?.let { NavTurnPill(it) { navigator.home() } }
if (call == null || roomForBoth) turn?.let { NavTurnPill(it) { if (!it.fromPhone) navigator.home() } }
if (call != null) {
NavCallPill(call, Modifier.weight(1f, fill = false))
} else if (turn == null || roomForBoth) {
@@ -416,8 +418,8 @@ private const val TITLE_SHOWN_MS = 3_000L
/** Below this, the nav bar's middle only has room for the turn, not the player beside it. */
private val TURN_AND_PLAYER_MIN_WIDTH = 520.dp
/** The next manoeuvre, as the running nav app last described it. */
private class NextTurn(val icon: CarIcon?, val distance: String, val road: String)
/** The next manoeuvre, as the running nav app last described it, or [fromPhone] a map guiding on the phone. */
private class NextTurn(val icon: CarIcon?, val distance: String, val road: String, val fromPhone: Boolean = false)
/**
* Read straight off the nav app's own template: a navigation app keeps sending its routing card
@@ -429,10 +431,10 @@ private fun rememberNextTurn(): NextTurn? {
val template by CarServices.nav.template.collectAsState()
// Read so a change of units redraws the distance straight away, not at the next update.
CarSettings.units.collectAsState().value
if (status.phase != CarAppConnection.Phase.RUNNING) return null
val routing = (template as? NavigationTemplate)?.navigationInfo as? RoutingInfo ?: return null
if (routing.isLoading) return null
val step = routing.currentStep ?: return null
val phone by PhoneNavigation.turn.collectAsState()
val routing = ((template as? NavigationTemplate)?.navigationInfo as? RoutingInfo)
?.takeIf { status.phase == CarAppConnection.Phase.RUNNING }
val step = routing?.takeIf { !it.isLoading }?.currentStep ?: return phone?.let { phoneTurn(it) }
return NextTurn(
icon = step.maneuver?.icon,
distance = routing.currentDistance.display(),
@@ -440,6 +442,15 @@ private fun rememberNextTurn(): NextTurn? {
)
}
/** Google Maps' or Waze's own arrow, and the distance as the app wrote it. */
@Composable
private fun phoneTurn(turn: PhoneNavigation.Turn): NextTurn {
val icon = remember(turn.arrow) {
turn.arrow?.let { CarIcon.Builder(androidx.core.graphics.drawable.IconCompat.createWithBitmap(it)).build() }
}
return NextTurn(icon, turn.distance?.display() ?: turn.distanceText, turn.road, fromPhone = true)
}
/** The next turn in the nav bar: arrow, how far, onto what. Tapping goes back to the map. */
@Composable
private fun NavTurnPill(turn: NextTurn, onClick: () -> Unit) {
@@ -8,6 +8,7 @@ import androidx.car.app.navigation.model.RoutingInfo
import androidx.car.app.navigation.model.Step
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.Protobuf
import app.seb3thehacker.gearslip.notify.PhoneNavigation
/**
* The next turn on the car's instrument cluster, over its NavigationStatusService (Service
@@ -200,6 +201,14 @@ internal class NavTurn(val step: Step, val distance: Distance?, val seconds: Lon
/** The map app's next turn: from its trip (what clusters get), or its routing card. */
fun current(): NavTurn? {
val nav = CarServices.navOrNull() ?: return null
hosted(nav)?.let { return it }
// Google Maps or Waze guiding on the phone, when no map in the car is.
val phone = PhoneNavigation.turn.value ?: return null
val step = Step.Builder(phone.road).setManeuver(Maneuver.Builder(phone.maneuver).build()).build()
return NavTurn(step, phone.distance, null, false)
}
private fun hosted(nav: app.seb3thehacker.gearslip.host.CarAppConnection): NavTurn? {
nav.trip.value?.let { trip ->
val step = trip.steps.firstOrNull() ?: return@let
val estimate = trip.stepTravelEstimates.firstOrNull()
@@ -27,7 +27,10 @@ class GearslipNotificationListener : NotificationListenerService() {
override fun onNotificationPosted(sbn: StatusBarNotification) = ingest(sbn, quiet = false)
override fun onNotificationRemoved(sbn: StatusBarNotification) = CarNotifications.gone(sbn.key)
override fun onNotificationRemoved(sbn: StatusBarNotification) {
PhoneNavigation.removed(sbn.key)
CarNotifications.gone(sbn.key)
}
private fun ingest(sbn: StatusBarNotification, quiet: Boolean) {
// Gearslip's own notifications are skipped, except a debug build's test message.
@@ -35,6 +38,11 @@ class GearslipNotificationListener : NotificationListenerService() {
!(app.seb3thehacker.gearslip.BuildConfig.DEBUG && sbn.notification.channelId == TestMessage.CHANNEL)
) return
val n = sbn.notification
// Google Maps or Waze guiding on the phone: the turn goes to the nav bar and the cluster.
if (n.category == Notification.CATEGORY_NAVIGATION && sbn.packageName in PhoneNavigation.APPS) {
PhoneNavigation.posted(this, sbn)
return
}
// Ongoing entries (media, navigation, downloads) and group headers are status, not news.
if (n.flags and (Notification.FLAG_ONGOING_EVENT or Notification.FLAG_GROUP_SUMMARY) != 0) return
if (n.category in QUIET_CATEGORIES) return
@@ -0,0 +1,147 @@
package app.seb3thehacker.gearslip.notify
import android.app.Notification
import android.content.Context
import android.graphics.Bitmap
import android.service.notification.StatusBarNotification
import androidx.car.app.model.Distance
import androidx.car.app.navigation.model.Maneuver
import androidx.core.graphics.drawable.toBitmap
import app.seb3thehacker.gearslip.GearslipLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* The next turn from a map app guiding on the phone itself. Google Maps and Waze refuse to run on
* Gearslip's car screen, but while one of them navigates on the phone it keeps a notification up
* with the turn's arrow, the distance and the road. Gearslip reads it, through the notification
* access it already has, and puts the turn in the nav bar and on the instrument cluster.
*/
object PhoneNavigation {
/** One turn as the notification described it. [maneuver] is a guess from the words, English only. */
class Turn(
val key: String,
val packageName: String,
val arrow: Bitmap?,
val distance: Distance?,
val distanceText: String,
val road: String,
val maneuver: Int,
)
private val _turn = MutableStateFlow<Turn?>(null)
val turn: StateFlow<Turn?> = _turn
/** The apps whose turns are read; any other map app runs on the car screen itself. */
val APPS = setOf("com.google.android.apps.maps", "com.waze")
private val log = GearslipLog.tagged("nav")
fun posted(context: Context, sbn: StatusBarNotification) {
val turn = parse(context, sbn)
val was = _turn.value
if (turn == null) {
if (was?.key == sbn.key) _turn.value = null
return
}
if (was == null || was.road != turn.road || was.maneuver != turn.maneuver) {
log.i("phone navigation (${sbn.packageName}): ${turn.distanceText} | ${turn.road} | maneuver=${turn.maneuver}")
}
_turn.value = turn
}
fun removed(key: String) {
if (_turn.value?.key == key) {
log.i("phone navigation ended")
_turn.value = null
}
}
/**
* Google Maps puts the distance in the title and the instruction in the text; Waze has been
* seen the other way round. Whichever field starts with a distance is the distance, and the
* other is the instruction.
*/
private fun parse(context: Context, sbn: StatusBarNotification): Turn? {
val extras = sbn.notification.extras
fun field(name: String) = extras.getCharSequence(name)?.toString()?.trim().orEmpty()
val fields = listOf(field(Notification.EXTRA_TITLE), field(Notification.EXTRA_TEXT), field(Notification.EXTRA_BIG_TEXT))
.filter { it.isNotEmpty() }
if (fields.isEmpty()) return null
val withDistance = fields.firstOrNull { DISTANCE.find(it)?.range?.first == 0 }
val match = withDistance?.let { DISTANCE.find(it) }
val rest = withDistance?.substring(match!!.range.last + 1)?.trimStart(' ', '·', '-', '•', ',')?.trim().orEmpty()
val instruction = fields.firstOrNull { it != withDistance }?.takeIf { it.isNotEmpty() } ?: rest
if (instruction.isEmpty() && match == null) return null
return Turn(
key = sbn.key,
packageName = sbn.packageName,
arrow = arrow(context, sbn.notification),
distance = match?.let { distanceOf(it) },
distanceText = match?.value?.trim().orEmpty(),
road = roadOf(instruction),
maneuver = maneuverOf(instruction),
)
}
private fun arrow(context: Context, n: Notification): Bitmap? = runCatching {
n.getLargeIcon()?.loadDrawable(context)?.toBitmap(ARROW_PX, ARROW_PX)
}.getOrNull()
private fun distanceOf(m: MatchResult): Distance? {
val value = m.groupValues[1].replace(',', '.').toDoubleOrNull() ?: return null
val unit = when (m.groupValues[2].lowercase()) {
"km" -> Distance.UNIT_KILOMETERS
"mi" -> Distance.UNIT_MILES
"ft" -> Distance.UNIT_FEET
"yd" -> Distance.UNIT_YARDS
else -> Distance.UNIT_METERS
}
val meters = value * when (unit) {
Distance.UNIT_KILOMETERS -> 1000.0
Distance.UNIT_MILES -> 1609.344
Distance.UNIT_FEET -> 0.3048
Distance.UNIT_YARDS -> 0.9144
else -> 1.0
}
return Distance.create(meters, unit)
}
/** "Turn left onto Main St" shows as "Main St"; anything else shows as it came. */
private fun roadOf(instruction: String): String {
val lower = instruction.lowercase()
for (word in ROAD_WORDS) {
val at = lower.indexOf(word)
if (at >= 0) return instruction.substring(at + word.length).trim()
}
return instruction
}
private fun maneuverOf(instruction: String): Int {
val s = instruction.lowercase()
val left = "left" in s
val right = "right" in s
fun side(l: Int, r: Int) = if (left) l else if (right) r else Maneuver.TYPE_UNKNOWN
return when {
"u-turn" in s || "make a u" in s -> side(Maneuver.TYPE_U_TURN_LEFT, Maneuver.TYPE_U_TURN_RIGHT)
"roundabout" in s -> Maneuver.TYPE_UNKNOWN // the car's icon needs an exit number we don't have
"destination" in s || "arrive" in s -> side(Maneuver.TYPE_DESTINATION_LEFT, Maneuver.TYPE_DESTINATION_RIGHT)
.takeIf { it != Maneuver.TYPE_UNKNOWN } ?: Maneuver.TYPE_DESTINATION
"exit" in s -> side(Maneuver.TYPE_OFF_RAMP_NORMAL_LEFT, Maneuver.TYPE_OFF_RAMP_NORMAL_RIGHT)
"merge" in s -> side(Maneuver.TYPE_MERGE_LEFT, Maneuver.TYPE_MERGE_RIGHT)
.takeIf { it != Maneuver.TYPE_UNKNOWN } ?: Maneuver.TYPE_MERGE_SIDE_UNSPECIFIED
"fork" in s -> side(Maneuver.TYPE_FORK_LEFT, Maneuver.TYPE_FORK_RIGHT)
"keep" in s || "stay" in s -> side(Maneuver.TYPE_KEEP_LEFT, Maneuver.TYPE_KEEP_RIGHT)
"slight" in s -> side(Maneuver.TYPE_TURN_SLIGHT_LEFT, Maneuver.TYPE_TURN_SLIGHT_RIGHT)
"sharp" in s -> side(Maneuver.TYPE_TURN_SHARP_LEFT, Maneuver.TYPE_TURN_SHARP_RIGHT)
"turn" in s -> side(Maneuver.TYPE_TURN_NORMAL_LEFT, Maneuver.TYPE_TURN_NORMAL_RIGHT)
"continue" in s || "straight" in s || "head " in s -> Maneuver.TYPE_STRAIGHT
else -> Maneuver.TYPE_UNKNOWN
}
}
private const val ARROW_PX = 96
private val DISTANCE = Regex("""^\s*(\d+(?:[.,]\d+)?)\s*(km|mi|ft|yd|m)\b""", RegexOption.IGNORE_CASE)
private val ROAD_WORDS = listOf(" onto ", " on to ", " toward ", " towards ", " on ")
}