Merge pull request #11 from iscle/feature/host-toasts

Show app toasts on the projected car display
This commit is contained in:
Seb3thehacker authored and GitHub committed 2026-10-09 14:23:50 -07:00
commit 0838bbb3cc
5 files changed
+190 -4

No files matched your search

@@ -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(
@@ -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
@@ -80,6 +81,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
@@ -215,6 +218,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
@@ -285,7 +289,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)
}
}
@@ -558,14 +562,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)
}
}
/**
@@ -665,7 +674,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()
@@ -754,10 +765,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)
}