voice: "navigate to X" picks the nearest X in the map app's own search and stops at its start screen; it no longer sends the phone's position, which Organic Maps took as the destination

This commit is contained in:
Seb3thehacker committed 2026-10-14 15:45:10 -07:00
1 parent 7ffa203221
commit 9b7734bec8
5 files changed
+113 -38

No files matched your search

@@ -131,7 +131,7 @@ object AssistantCommands {
} }
} }
// "the nearest gas station" -> "gas station": the map app already searches around the car // "the nearest gas station" -> "gas station": the map app already searches around the car
// (see CarMotion.nearHere), and Organic Maps took "nearest" as part of the name. // and AutoRoute picks the nearest result, and Organic Maps took "nearest" as part of the name.
val (place, nearby) = plainPlace(destination) val (place, nearby) = plainPlace(destination)
destination = place destination = place
if (destination.isBlank()) return "Where to?" if (destination.isBlank()) return "Where to?"
@@ -0,0 +1,98 @@
package app.seb3thehacker.gearslip.host
import android.os.SystemClock
import android.text.Spanned
import androidx.car.app.model.CarText
import androidx.car.app.model.DistanceSpan
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.PaneTemplate
import androidx.car.app.model.PlaceListMapTemplate
import androidx.car.app.model.Row
import androidx.car.app.model.SearchTemplate
import androidx.car.app.model.Template
import androidx.car.app.navigation.model.MapTemplate
import androidx.car.app.navigation.model.MapWithContentTemplate
import androidx.car.app.navigation.model.NavigationTemplate
import androidx.car.app.navigation.model.PlaceListNavigationTemplate
import androidx.car.app.navigation.model.RoutePreviewNavigationTemplate
import app.seb3thehacker.gearslip.GearslipLog
/**
* "Navigate to X" the way Android Auto does it: straight onto a route, no list to pick from.
*
* Android Auto looks the place up on Google's servers first. Gearslip stays on the phone, so it
* asks the map app to search, then taps the nearest result in the app's list for the driver.
* It stops at the app's start screen, so the driver sees where it's going and taps Start. It
* gives up after [WINDOW_MS], or as soon as the driver taps anything themselves.
*/
internal class AutoRoute {
private val log = GearslipLog.tagged("route")
private var until = 0L
private var pickedRow = false
/** A destination was just sent to the map app. */
fun arm() {
armedAt = SystemClock.elapsedRealtime()
until = armedAt + WINDOW_MS
pickedRow = false
}
fun disarm() {
until = 0L
}
private var armedAt = 0L
private val armed get() = SystemClock.elapsedRealtime() < until && driverTappedAt < armedAt
/** Each screen the map app sends while armed: tap the next step if it's there. */
@Suppress("DEPRECATION") // Apps still send the older navigation templates.
fun onTemplate(template: Template) {
if (!armed) return
when (template) {
is NavigationTemplate -> disarm()
// The start screen: the driver takes it from here.
is RoutePreviewNavigationTemplate -> arrived()
is MapWithContentTemplate -> onTemplate(template.contentTemplate)
is PaneTemplate -> arrived()
is MapTemplate -> if (template.pane != null) arrived() else template.itemList?.let(::onList)
is SearchTemplate -> if (!template.isLoading) template.itemList?.let(::onList)
is ListTemplate -> if (!template.isLoading) template.singleList?.let(::onList)
is PlaceListMapTemplate -> if (!template.isLoading) template.itemList?.let(::onList)
is PlaceListNavigationTemplate -> if (!template.isLoading) template.itemList?.let(::onList)
}
}
/** Search results: the nearest place, or the first when the app gives no distances. */
private fun onList(list: ItemList) {
if (pickedRow) return
val places = list.items.filterIsInstance<Row>()
.filter { it.isEnabled && it.onClickDelegate != null && !it.isBrowsable }
if (places.isEmpty()) return
val nearest = places.minByOrNull { it.meters() ?: Double.MAX_VALUE } ?: return
pickedRow = true
log.i("picking the nearest of ${places.size} results")
nearest.onClickDelegate.click(AUTO_LABEL)
}
private fun arrived() {
if (pickedRow) log.i("at the start screen")
disarm()
}
/** The first distance the app attached to this row's title or text lines. */
private fun Row.meters(): Double? = (listOfNotNull(title) + texts).firstNotNullOfOrNull { it.distanceMeters() }
private fun CarText.distanceMeters(): Double? {
val chars = toCharSequence() as? Spanned ?: return null
return chars.getSpans(0, chars.length, DistanceSpan::class.java).firstOrNull()?.distance?.meters()
}
companion object {
/** Taps sent by this class carry this label, so they don't count as the driver's. */
const val AUTO_LABEL = "auto-route"
/** When the driver last tapped one of the app's buttons; that hands control back. */
@Volatile var driverTappedAt = 0L
private const val WINDOW_MS = 30_000L
}
}
@@ -243,6 +243,7 @@ class CarAppConnection(private val context: Context) {
navigating = false navigating = false
main.removeCallbacks(sendReroute) main.removeCallbacks(sendReroute)
reroute = null reroute = null
autoRoute.disarm()
_panMode.value = false _panMode.value = false
surfaceCallback = null surfaceCallback = null
component = null component = null
@@ -293,7 +294,7 @@ class CarAppConnection(private val context: Context) {
private fun onHandshakeAccepted() { private fun onHandshakeAccepted() {
val app = carApp ?: return val app = carApp ?: return
GearslipLog.i("host: handshake accepted; creating the app") GearslipLog.i("host: handshake accepted; creating the app")
val intent = pendingDestination?.let { navigateIntent(it) } val intent = pendingDestination?.let { autoRoute.arm(); navigateIntent(it) }
?: Intent(Intent.ACTION_MAIN).setComponent(component) ?: Intent(Intent.ACTION_MAIN).setComponent(component)
pendingDestination = null pendingDestination = null
call("onAppCreate", onValue = { onCreated() }) { call("onAppCreate", onValue = { onCreated() }) {
@@ -303,15 +304,14 @@ class CarAppConnection(private val context: Context) {
/** /**
* "navigate to X" deep link, the same one Android Auto's own Assistant integration sends - * "navigate to X" deep link, the same one Android Auto's own Assistant integration sends -
* [ACTION_NAVIGATE] with the destination as a `geo:` search query, rather than anything * [ACTION_NAVIGATE] with the destination as a `geo:0,0` search query, rather than anything
* app-specific. Centred on the phone when there's a fix: `geo:0,0` made Organic Maps pick the * app-specific. The app searches near its own location; [AutoRoute] then picks the nearest
* first Del Taco it found anywhere, not the nearest. The location goes only to the map app. * result. Coordinates in the link would mean "go exactly here": Organic Maps routed to the
* phone's own position when it was given them.
*/ */
private fun navigateIntent(destination: String): Intent { private fun navigateIntent(destination: String): Intent =
val near = app.seb3thehacker.gearslip.notify.CarMotion.nearHere(context) ?: "0,0" Intent(CarContext.ACTION_NAVIGATE, Uri.parse("geo:0,0?q=" + Uri.encode(destination)))
return Intent(CarContext.ACTION_NAVIGATE, Uri.parse("geo:$near?q=" + Uri.encode(destination)))
.setComponent(component) .setComponent(component)
}
/** /**
* Sends a fresh destination to an app that's already running, instead of tearing it down * Sends a fresh destination to an app that's already running, instead of tearing it down
@@ -336,11 +336,14 @@ class CarAppConnection(private val context: Context) {
private fun sendDestination(destination: String) { private fun sendDestination(destination: String) {
val app = carApp ?: return val app = carApp ?: return
autoRoute.arm()
call("onNewIntent", onValue = {}) { call("onNewIntent", onValue = {}) {
app.onNewIntent(navigateIntent(destination), it) app.onNewIntent(navigateIntent(destination), it)
} }
} }
private val autoRoute = AutoRoute()
/** A destination waiting for the current route to end; see [navigateTo]. */ /** A destination waiting for the current route to end; see [navigateTo]. */
private var reroute: String? = null private var reroute: String? = null
private val sendReroute = Runnable { private val sendReroute = Runnable {
@@ -422,6 +425,7 @@ class CarAppConnection(private val context: Context) {
GearslipLog.i("host: template = ${template.javaClass.simpleName}") GearslipLog.i("host: template = ${template.javaClass.simpleName}")
_template.value = template _template.value = template
update { it.copy(template = template.javaClass.simpleName) } update { it.copy(template = template.javaClass.simpleName) }
autoRoute.onTemplate(template)
}) { manager.getTemplate(it) } }) { manager.getTemplate(it) }
} }
@@ -141,7 +141,7 @@ fun Distance?.display(): String {
return "%.${decimals}f $unit".format(distance.displayDistance) return "%.${decimals}f $unit".format(distance.displayDistance)
} }
private fun Distance.meters(): Double = displayDistance * when (displayUnit) { internal fun Distance.meters(): Double = displayDistance * when (displayUnit) {
Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> 1000.0 Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> 1000.0
Distance.UNIT_MILES, Distance.UNIT_MILES_P1 -> 1609.344 Distance.UNIT_MILES, Distance.UNIT_MILES_P1 -> 1609.344
Distance.UNIT_FEET -> 0.3048 Distance.UNIT_FEET -> 0.3048
@@ -195,6 +195,7 @@ fun androidx.car.app.model.DateTimeWithZone?.clockTime(): String {
*/ */
fun OnClickDelegate?.click(label: String = "action") { fun OnClickDelegate?.click(label: String = "action") {
val delegate = this ?: return val delegate = this ?: return
if (label != AutoRoute.AUTO_LABEL) AutoRoute.driverTappedAt = android.os.SystemClock.elapsedRealtime()
runCatching { delegate.sendClick(doneCallback("a $label tap")) } runCatching { delegate.sendClick(doneCallback("a $label tap")) }
.onFailure { GearslipLog.w("host: could not send a $label tap: ${it.message}") } .onFailure { GearslipLog.w("host: could not send a $label tap: ${it.message}") }
} }
@@ -22,33 +22,6 @@ object CarMotion {
private var listener: LocationListener? = null private var listener: LocationListener? = null
private var speed = 0f private var speed = 0f
private var fixAt = 0L private var fixAt = 0L
@Volatile private var last: Location? = null
/** A fix older than this is somewhere the car has left. */
private const val NEAR_STALE_MS = 10 * 60_000L
/**
* Where the phone is, as "lat,lng", for "navigate to Del Taco" to find the nearest one. From
* this drive's GPS, or Android's last known fix; null with no permission or nothing recent.
*/
fun nearHere(context: Context): String? {
val fix = last?.takeIf { SystemClock.elapsedRealtime() - fixAt < NEAR_STALE_MS }
?: lastKnown(context)
?: return null
return String.format(java.util.Locale.US, "%.5f,%.5f", fix.latitude, fix.longitude)
}
private fun lastKnown(context: Context): Location? {
if (context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED &&
context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED
) return null
val manager = context.getSystemService(LocationManager::class.java) ?: return null
val now = System.currentTimeMillis()
return manager.getProviders(true)
.mapNotNull { runCatching { manager.getLastKnownLocation(it) }.getOrNull() }
.filter { now - it.time < NEAR_STALE_MS }
.maxByOrNull { it.time }
}
val moving: Boolean val moving: Boolean
get() = SystemClock.elapsedRealtime() - fixAt < STALE_MS && speed > MOVING_MS get() = SystemClock.elapsedRealtime() - fixAt < STALE_MS && speed > MOVING_MS
@@ -61,7 +34,6 @@ object CarMotion {
override fun onLocationChanged(location: Location) { override fun onLocationChanged(location: Location) {
speed = if (location.hasSpeed()) location.speed else 0f speed = if (location.hasSpeed()) location.speed else 0f
fixAt = SystemClock.elapsedRealtime() fixAt = SystemClock.elapsedRealtime()
last = location
} }
} }
listener = l listener = l