diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/AssistantCommands.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/AssistantCommands.kt index b635a57..49d233f 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/AssistantCommands.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/AssistantCommands.kt @@ -131,7 +131,7 @@ object AssistantCommands { } } // "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) destination = place if (destination.isBlank()) return "Where to?" diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/AutoRoute.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/AutoRoute.kt new file mode 100644 index 0000000..78920c7 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/AutoRoute.kt @@ -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() + .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 + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt index 55414f8..0be82f8 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt @@ -243,6 +243,7 @@ class CarAppConnection(private val context: Context) { navigating = false main.removeCallbacks(sendReroute) reroute = null + autoRoute.disarm() _panMode.value = false surfaceCallback = null component = null @@ -293,7 +294,7 @@ class CarAppConnection(private val context: Context) { private fun onHandshakeAccepted() { val app = carApp ?: return 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) pendingDestination = null 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 - - * [ACTION_NAVIGATE] with the destination as a `geo:` search query, rather than anything - * app-specific. Centred on the phone when there's a fix: `geo:0,0` made Organic Maps pick the - * first Del Taco it found anywhere, not the nearest. The location goes only to the map app. + * [ACTION_NAVIGATE] with the destination as a `geo:0,0` search query, rather than anything + * app-specific. The app searches near its own location; [AutoRoute] then picks the nearest + * 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 { - val near = app.seb3thehacker.gearslip.notify.CarMotion.nearHere(context) ?: "0,0" - return Intent(CarContext.ACTION_NAVIGATE, Uri.parse("geo:$near?q=" + Uri.encode(destination))) + private fun navigateIntent(destination: String): Intent = + Intent(CarContext.ACTION_NAVIGATE, Uri.parse("geo:0,0?q=" + Uri.encode(destination))) .setComponent(component) - } /** * 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) { val app = carApp ?: return + autoRoute.arm() call("onNewIntent", onValue = {}) { app.onNewIntent(navigateIntent(destination), it) } } + private val autoRoute = AutoRoute() + /** A destination waiting for the current route to end; see [navigateTo]. */ private var reroute: String? = null private val sendReroute = Runnable { @@ -422,6 +425,7 @@ class CarAppConnection(private val context: Context) { GearslipLog.i("host: template = ${template.javaClass.simpleName}") _template.value = template update { it.copy(template = template.javaClass.simpleName) } + autoRoute.onTemplate(template) }) { manager.getTemplate(it) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt index 5db0845..f4fc8ed 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt @@ -141,7 +141,7 @@ fun Distance?.display(): String { 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_MILES, Distance.UNIT_MILES_P1 -> 1609.344 Distance.UNIT_FEET -> 0.3048 @@ -195,6 +195,7 @@ fun androidx.car.app.model.DateTimeWithZone?.clockTime(): String { */ fun OnClickDelegate?.click(label: String = "action") { val delegate = this ?: return + if (label != AutoRoute.AUTO_LABEL) AutoRoute.driverTappedAt = android.os.SystemClock.elapsedRealtime() runCatching { delegate.sendClick(doneCallback("a $label tap")) } .onFailure { GearslipLog.w("host: could not send a $label tap: ${it.message}") } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/CarMotion.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/CarMotion.kt index 3d95990..fc87fbc 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/CarMotion.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/CarMotion.kt @@ -22,33 +22,6 @@ object CarMotion { private var listener: LocationListener? = null private var speed = 0f 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 get() = SystemClock.elapsedRealtime() - fixAt < STALE_MS && speed > MOVING_MS @@ -61,7 +34,6 @@ object CarMotion { override fun onLocationChanged(location: Location) { speed = if (location.hasSpeed()) location.speed else 0f fixAt = SystemClock.elapsedRealtime() - last = location } } listener = l