From cc78020eda4a8ebadac6d4841c43b7b894b795fa Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Tue, 3 Nov 2026 12:27:07 -0800 Subject: [PATCH] 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. --- .../seb3thehacker/gearslip/car/CarNavBar.kt | 27 +++- .../seb3thehacker/gearslip/car/ClusterNav.kt | 9 ++ .../notify/GearslipNotificationListener.kt | 10 +- .../gearslip/notify/PhoneNavigation.kt | 147 ++++++++++++++++++ 4 files changed, 184 insertions(+), 9 deletions(-) create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/notify/PhoneNavigation.kt diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavBar.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavBar.kt index c2382a4..dd849eb 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavBar.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavBar.kt @@ -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) { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ClusterNav.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ClusterNav.kt index ca91662..a5f3ca6 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ClusterNav.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ClusterNav.kt @@ -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() diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/GearslipNotificationListener.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/GearslipNotificationListener.kt index b6d588a..4d81117 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/GearslipNotificationListener.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/GearslipNotificationListener.kt @@ -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 diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/PhoneNavigation.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/PhoneNavigation.kt new file mode 100644 index 0000000..e4b56f7 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/PhoneNavigation.kt @@ -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(null) + val turn: StateFlow = _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 ") +}