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