From 405cecf2e96083ee1539dad6ba3943db5ebfe555 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Fri, 23 Oct 2026 13:01:42 -0700 Subject: [PATCH] car apps: say an app won't start when it never answers, instead of showing Starting for ever Apps on Car App Library 1.9 and later (Yandex Maps and 2GIS among them) don't reply to a host that isn't Google's, so a map app on Home sat on its loading message. After 10 seconds without an answer it now counts as refused, and Home offers another app. --- .../gearslip/host/CarAppConnection.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 dbca04d..d506129 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt @@ -226,6 +226,7 @@ class CarAppConnection(private val context: Context) { fun disconnect() = disconnect(keepLocation = false) private fun disconnect(keepLocation: Boolean) { + main.removeCallbacks(handshakeTimeout) clearToasts() lent?.let { sendSurfaceDestroyed(it) } lent = null @@ -257,11 +258,24 @@ class CarAppConnection(private val context: Context) { // --- handshake --------------------------------------------------------------------- + /** + * An app that won't have Gearslip as its host never answers the first question. Car App + * Library 1.9 and later throw inside the app when a host that isn't Google's asks, and since + * the call is one-way the error never comes back, so without this the car would show + * "Starting..." for ever. + */ + private val handshakeTimeout = Runnable { + if (_status.value.phase == Phase.HANDSHAKE) { + fail(Phase.REJECTED, "It didn't answer Gearslip. Apps built for newer versions of Android Auto only run on Google's.") + } + } + private fun onBound(service: IBinder) { val app = ICarApp.Stub.asInterface(service) carApp = app update { it.copy(phase = Phase.HANDSHAKE, detail = "bound, asking for app info") } GearslipLog.i("host: bound; requesting app info") + main.postDelayed(handshakeTimeout, HANDSHAKE_TIMEOUT_MS) call("getAppInfo", onValue = { value -> val info = value as? AppInfo ?: return@call fail(Phase.FAILED, "app info was ${value?.javaClass?.simpleName}") @@ -274,6 +288,7 @@ class CarAppConnection(private val context: Context) { * offers cannot be rendered correctly, so it is refused here rather than half-drawn. */ private fun onAppInfo(info: AppInfo) { + main.removeCallbacks(handshakeTimeout) val negotiated = minOf(HOST_API_LEVEL, info.latestCarAppApiLevel) GearslipLog.i( "host: app api ${info.minCarAppApiLevel}..${info.latestCarAppApiLevel} " + @@ -839,6 +854,9 @@ class CarAppConnection(private val context: Context) { /** How long a new destination waits for the old route to end; see [navigateTo]. */ const val REROUTE_WAIT_MS = 1_500L + /** How long an app gets to answer the first question before it counts as refusing. */ + const val HANDSHAKE_TIMEOUT_MS = 10_000L + /** Context.BIND_FOREGROUND_SERVICE; the constant itself is hidden from the SDK. */ const val BIND_FOREGROUND_SERVICE = 0x04000000 }