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.
[<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
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
@@ -187,6 +187,8 @@ private fun groupOf(entry: Entry, mediaPackages: Set<String>): 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
@@ -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()
// The dashboard is already listing them, so it needs no popup.
NotificationPopup(
@@ -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",
)
}
@@ -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?> = _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)
}
@@ -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)
}
}