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
|
// "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
|
||||||
|
|||||||
Reference in new issue
Block a user