Merge remote-tracking branch 'origin/experimental' into experimental

This commit is contained in:
Seb3thehacker committed 2026-10-09 14:24:28 -07:00
commit 006823232d
8 files changed
+201 -6

No files matched your search

+7 -2
View File
@@ -4,6 +4,13 @@
An Android Auto alternative built for everyone. An Android Auto alternative built for everyone.
[<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="54">](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 ## What Gearslip is
Gearslip is an Android Auto client for the phone. It speaks the Android Auto protocol 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 - **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 Google's car library that accepts only Android Auto itself. Spotify still plays through
Gearslip's own player. 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 - **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the
car to hear calls through its speakers. car to hear calls through its speakers.
- **Audio reaches the car by capturing playback, not by mirroring the screen.** Android - **Audio reaches the car by capturing playback, not by mirroring the screen.** Android
@@ -187,6 +187,8 @@ private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
return when (app.kind) { return when (app.kind) {
"calling" -> AppGroup.PHONE "calling" -> AppGroup.PHONE
"navigation", "poi", "parking", "charging" -> AppGroup.MAPS "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 "messaging" -> AppGroup.MESSAGING
"settings" -> AppGroup.SETTINGS "settings" -> AppGroup.SETTINGS
else -> AppGroup.OTHER else -> AppGroup.OTHER
@@ -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,
)
}
}
@@ -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<Message?>(null)
val message: StateFlow<Message?> = 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
}
}
@@ -226,6 +226,7 @@ fun CarUi() {
} }
} }
} }
CarToastOverlay(Modifier.align(Alignment.BottomCenter))
PopupTimer() PopupTimer()
// The dashboard is already listing them, so it needs no popup. // The dashboard is already listing them, so it needs no popup.
NotificationPopup( NotificationPopup(
@@ -78,5 +78,7 @@ object CarAppCatalog {
CarAppService.CATEGORY_SETTINGS_APP, CarAppService.CATEGORY_SETTINGS_APP,
CarAppService.CATEGORY_IOT_APP, CarAppService.CATEGORY_IOT_APP,
CarAppService.CATEGORY_WEATHER_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",
) )
} }
@@ -1,6 +1,7 @@
package app.seb3thehacker.gearslip.host package app.seb3thehacker.gearslip.host
import app.seb3thehacker.gearslip.car.CarEnvironment import app.seb3thehacker.gearslip.car.CarEnvironment
import app.seb3thehacker.gearslip.car.CarToasts
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -81,6 +82,8 @@ class CarAppConnection(private val context: Context) {
val template: StateFlow<Template?> = _template.asStateFlow() val template: StateFlow<Template?> = _template.asStateFlow()
private val main = Handler(Looper.getMainLooper()) 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 carApp: ICarApp? = null
private var appManager: IAppManager? = null private var appManager: IAppManager? = null
@@ -220,6 +223,7 @@ class CarAppConnection(private val context: Context) {
fun disconnect() = disconnect(keepLocation = false) fun disconnect() = disconnect(keepLocation = false)
private fun disconnect(keepLocation: Boolean) { private fun disconnect(keepLocation: Boolean) {
clearToasts()
lent?.let { sendSurfaceDestroyed(it) } lent?.let { sendSurfaceDestroyed(it) }
lent = null lent = null
val carApp = this.carApp val carApp = this.carApp
@@ -290,7 +294,7 @@ class CarAppConnection(private val context: Context) {
?: Intent(Intent.ACTION_MAIN).setComponent(component) ?: Intent(Intent.ACTION_MAIN).setComponent(component)
pendingDestination = null pendingDestination = null
call("onAppCreate", onValue = { onCreated() }) { 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 ----------------------------------------- // --- host interfaces the app calls back into -----------------------------------------
private val appHost = object : IAppHost.Stub() { private fun createAppHost(owner: Any) = object : IAppHost.Stub() {
override fun invalidate() { override fun invalidate() {
GearslipLog.i("host: app invalidated its template") GearslipLog.i("host: app invalidated its template")
main.post { requestTemplate() } main.post { requestTemplate() }
} }
override fun showToast(text: CharSequence?, duration: Int) { 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 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) { override fun getHost(type: String?): IBinder? = when (type) {
CarContext.APP_SERVICE -> appHost.asBinder() CarContext.APP_SERVICE -> appHost.asBinder()
CarContext.NAVIGATION_SERVICE -> navigationHost.asBinder() CarContext.NAVIGATION_SERVICE -> navigationHost.asBinder()
@@ -759,10 +770,16 @@ class CarAppConnection(private val context: Context) {
} }
private fun fail(phase: Phase, detail: String) { private fun fail(phase: Phase, detail: String) {
clearToasts()
GearslipLog.e("host: $detail") GearslipLog.e("host: $detail")
update { it.copy(phase = phase, detail = detail) } update { it.copy(phase = phase, detail = detail) }
} }
private fun clearToasts() {
CarToasts.clear(toastOwner)
toastOwner = Any()
}
private fun update(block: (Status) -> Status) { private fun update(block: (Status) -> Status) {
_status.value = block(_status.value) _status.value = block(_status.value)
} }
@@ -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)
}
}