mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 00:12:24 +01:00
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:
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
|
||||
// (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?"
|
||||
|
||||
@@ -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
|
||||
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) }
|
||||
}
|
||||
|
||||
|
||||
@@ -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}") }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user