diff --git a/README.md b/README.md
index 704c7c1..b496660 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,13 @@
An Android Auto alternative built for everyone.
+[
](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://github.com/Seb3thehacker/gearslip)
+
+## Which cars work
+
+Most cars work. Starting around 2020, some cars added newer security standards that reject
+Gearslip's certificate, and Gearslip can't run in those cars.
+
## What Gearslip is
Gearslip is an Android Auto client for the phone. It speaks the Android Auto protocol
@@ -61,8 +68,6 @@ mode.
- **Some apps refuse Gearslip.** Spotify, Waze, and Google Messages use a version of
Google's car library that accepts only Android Auto itself. Spotify still plays through
Gearslip's own player.
-- **Gearslip works best with cars from before 2020.** Some newer head units reject its
- certificate.
- **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the
car to hear calls through its speakers.
- **Audio reaches the car by capturing playback, not by mirroring the screen.** Android
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt
index 6669c07..5a4e7d0 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt
@@ -187,6 +187,8 @@ private fun groupOf(entry: Entry, mediaPackages: Set): AppGroup {
return when (app.kind) {
"calling" -> AppGroup.PHONE
"navigation", "poi", "parking", "charging" -> AppGroup.MAPS
+ // Template-only media apps may have no legacy browser/player tile to identify them.
+ "media" -> AppGroup.MUSIC
"messaging" -> AppGroup.MESSAGING
"settings" -> AppGroup.SETTINGS
else -> AppGroup.OTHER
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToastOverlay.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToastOverlay.kt
new file mode 100644
index 0000000..c2dab2a
--- /dev/null
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToastOverlay.kt
@@ -0,0 +1,55 @@
+package app.seb3thehacker.gearslip.car
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalAccessibilityManager
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.delay
+
+/** Draw inside the encoded car display; a normal Android Toast would appear on the phone. */
+@Composable
+internal fun CarToastOverlay(modifier: Modifier = Modifier) {
+ val current by CarToasts.message.collectAsState()
+ val message = current ?: return
+ val accessibility = LocalAccessibilityManager.current
+ LaunchedEffect(message, accessibility) {
+ val timeout = accessibility?.calculateRecommendedTimeoutMillis(
+ message.durationMs, containsIcons = false, containsText = true, containsControls = false,
+ ) ?: message.durationMs
+ delay(timeout)
+ CarToasts.dismiss(message)
+ }
+
+ // A non-interactive overlay leaves the map's touch handling and focus intact.
+ Box(
+ modifier
+ .padding(horizontal = 24.dp, vertical = 16.dp)
+ .widthIn(max = 560.dp)
+ .clip(MaterialTheme.shapes.medium)
+ .background(MaterialTheme.colorScheme.inverseSurface)
+ .semantics { liveRegion = LiveRegionMode.Polite },
+ ) {
+ Text(
+ message.text,
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
+ color = MaterialTheme.colorScheme.inverseOnSurface,
+ style = MaterialTheme.typography.bodyLarge,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToasts.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToasts.kt
new file mode 100644
index 0000000..fc8d569
--- /dev/null
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToasts.kt
@@ -0,0 +1,33 @@
+package app.seb3thehacker.gearslip.car
+
+import androidx.annotation.MainThread
+import androidx.car.app.CarToast
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/** One toast across navigation and browse apps; newer requests replace stale messages. */
+@MainThread
+internal object CarToasts {
+ // Identity matters: repeating identical text must restart its display timer.
+ class Message(val owner: Any, val text: String, val durationMs: Long)
+
+ private val current = MutableStateFlow(null)
+ val message: StateFlow = current.asStateFlow()
+
+ fun show(owner: Any, text: String, duration: Int) {
+ if (text.isBlank()) return
+ val durationMs = if (duration == CarToast.LENGTH_LONG) 3_500L else 2_000L
+ current.value = Message(owner, text, durationMs)
+ }
+
+ fun dismiss(message: Message) {
+ // An old timer must not dismiss a newer toast, even if its text is identical.
+ if (current.value === message) current.value = null
+ }
+
+ fun clear(owner: Any) {
+ // Disconnecting the map must not clear a toast belonging to the browse app.
+ if (current.value?.owner === owner) current.value = null
+ }
+}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt
index 261bcfd..6bdf7c0 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt
@@ -226,6 +226,7 @@ fun CarUi() {
}
}
}
+ CarToastOverlay(Modifier.align(Alignment.BottomCenter))
PopupTimer()
// The dashboard is already listing them, so it needs no popup.
NotificationPopup(
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppCatalog.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppCatalog.kt
index a9b2ca6..31ad0db 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppCatalog.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppCatalog.kt
@@ -78,5 +78,7 @@ object CarAppCatalog {
CarAppService.CATEGORY_SETTINGS_APP,
CarAppService.CATEGORY_IOT_APP,
CarAppService.CATEGORY_WEATHER_APP,
+ // Newer apps declare MEDIA, but our AndroidX 1.7 dependency has no constant for it.
+ "androidx.car.app.category.MEDIA",
)
}
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 1521f64..14d8e0a 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt
@@ -1,6 +1,7 @@
package app.seb3thehacker.gearslip.host
import app.seb3thehacker.gearslip.car.CarEnvironment
+import app.seb3thehacker.gearslip.car.CarToasts
import android.content.ComponentName
import android.content.Context
import android.content.Intent
@@ -81,6 +82,8 @@ class CarAppConnection(private val context: Context) {
val template: StateFlow = _template.asStateFlow()
private val main = Handler(Looper.getMainLooper())
+ /** Captured by each host Binder so callbacks queued by a replaced app cannot show a toast. */
+ private var toastOwner = Any()
private var carApp: ICarApp? = null
private var appManager: IAppManager? = null
@@ -220,6 +223,7 @@ class CarAppConnection(private val context: Context) {
fun disconnect() = disconnect(keepLocation = false)
private fun disconnect(keepLocation: Boolean) {
+ clearToasts()
lent?.let { sendSurfaceDestroyed(it) }
lent = null
val carApp = this.carApp
@@ -290,7 +294,7 @@ class CarAppConnection(private val context: Context) {
?: Intent(Intent.ACTION_MAIN).setComponent(component)
pendingDestination = null
call("onAppCreate", onValue = { onCreated() }) {
- app.onAppCreate(carHost, intent, carConfiguration(), it)
+ app.onAppCreate(createCarHost(toastOwner), intent, carConfiguration(), it)
}
}
@@ -563,14 +567,19 @@ class CarAppConnection(private val context: Context) {
// --- host interfaces the app calls back into -----------------------------------------
- private val appHost = object : IAppHost.Stub() {
+ private fun createAppHost(owner: Any) = object : IAppHost.Stub() {
override fun invalidate() {
GearslipLog.i("host: app invalidated its template")
main.post { requestTemplate() }
}
override fun showToast(text: CharSequence?, duration: Int) {
- GearslipLog.i("host: toast \"$text\"")
+ val message = text?.toString() ?: return
+ main.post {
+ if (toastOwner !== owner) return@post
+ GearslipLog.i("host: toast \"$message\"")
+ CarToasts.show(owner, message, duration)
+ }
}
/**
@@ -670,7 +679,9 @@ class CarAppConnection(private val context: Context) {
override fun updateSuggestions(suggestions: Bundleable?) = Unit
}
- private val carHost = object : ICarHost.Stub() {
+ private fun createCarHost(owner: Any) = object : ICarHost.Stub() {
+ private val appHost = createAppHost(owner)
+
override fun getHost(type: String?): IBinder? = when (type) {
CarContext.APP_SERVICE -> appHost.asBinder()
CarContext.NAVIGATION_SERVICE -> navigationHost.asBinder()
@@ -759,10 +770,16 @@ class CarAppConnection(private val context: Context) {
}
private fun fail(phase: Phase, detail: String) {
+ clearToasts()
GearslipLog.e("host: $detail")
update { it.copy(phase = phase, detail = detail) }
}
+ private fun clearToasts() {
+ CarToasts.clear(toastOwner)
+ toastOwner = Any()
+ }
+
private fun update(block: (Status) -> Status) {
_status.value = block(_status.value)
}
diff --git a/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarToastsTest.kt b/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarToastsTest.kt
new file mode 100644
index 0000000..a25fef1
--- /dev/null
+++ b/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarToastsTest.kt
@@ -0,0 +1,80 @@
+package app.seb3thehacker.gearslip.car
+
+import androidx.car.app.CarToast
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotSame
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+import org.junit.Before
+import org.junit.Test
+
+class CarToastsTest {
+ @Before
+ @After
+ fun clearToast() {
+ CarToasts.message.value?.let(CarToasts::dismiss)
+ }
+
+ @Test
+ fun expiredToastCannotDismissItsReplacement() {
+ val owner = Any()
+ CarToasts.show(owner, "First", CarToast.LENGTH_SHORT)
+ val first = requireNotNull(CarToasts.message.value)
+ CarToasts.show(owner, "Second", CarToast.LENGTH_LONG)
+ val second = requireNotNull(CarToasts.message.value)
+
+ CarToasts.dismiss(first)
+ assertSame(second, CarToasts.message.value)
+ CarToasts.dismiss(second)
+ assertNull(CarToasts.message.value)
+ }
+
+ @Test
+ fun identicalMessagesStillRestartTheDisplayTimer() {
+ val owner = Any()
+ CarToasts.show(owner, "Saved", CarToast.LENGTH_SHORT)
+ val first = requireNotNull(CarToasts.message.value)
+ CarToasts.show(owner, "Saved", CarToast.LENGTH_SHORT)
+ val repeated = requireNotNull(CarToasts.message.value)
+
+ assertNotSame(first, repeated)
+ CarToasts.dismiss(first)
+ assertSame(repeated, CarToasts.message.value)
+ }
+
+ @Test
+ fun disconnectOnlyClearsItsOwnToast() {
+ val nav = Any()
+ val browse = Any()
+ CarToasts.show(nav, "Route saved", CarToast.LENGTH_SHORT)
+ CarToasts.show(browse, "Added to playlist", CarToast.LENGTH_SHORT)
+ val latest = CarToasts.message.value
+
+ CarToasts.clear(nav)
+ assertSame(latest, CarToasts.message.value)
+ CarToasts.clear(browse)
+ assertNull(CarToasts.message.value)
+ }
+
+ @Test
+ fun blankRequestsDoNotReplaceAVisibleMessage() {
+ val owner = Any()
+ CarToasts.show(owner, "Saved", CarToast.LENGTH_SHORT)
+ val visible = CarToasts.message.value
+
+ CarToasts.show(owner, " \n\t", CarToast.LENGTH_LONG)
+ assertSame(visible, CarToasts.message.value)
+ }
+
+ @Test
+ fun unknownDurationsFallBackToShortRatherThanStayingVisible() {
+ val owner = Any()
+ CarToasts.show(owner, "Short", CarToast.LENGTH_SHORT)
+ assertEquals(2_000L, CarToasts.message.value?.durationMs)
+ CarToasts.show(owner, "Long", CarToast.LENGTH_LONG)
+ assertEquals(3_500L, CarToasts.message.value?.durationMs)
+ CarToasts.show(owner, "Unknown", Int.MAX_VALUE)
+ assertEquals(2_000L, CarToasts.message.value?.durationMs)
+ }
+}