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 ") +}