From 23f12884da0b693b807013b7c8b41ce4c3bf89f1 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Fri, 9 Oct 2026 15:26:48 -0700 Subject: [PATCH] car toasts: take turns instead of replacing each other A new toast waits until the one on screen has had 2 seconds. Repeats restart the visible toast, and a burst keeps only the newest three waiting. --- .../gearslip/car/CarToastOverlay.kt | 8 +++- .../seb3thehacker/gearslip/car/CarToasts.kt | 39 +++++++++++++++++-- .../gearslip/car/CarToastsTest.kt | 37 ++++++++++++++++-- 3 files changed, 75 insertions(+), 9 deletions(-) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToastOverlay.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToastOverlay.kt index 7416e01..fb877e7 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToastOverlay.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToastOverlay.kt @@ -20,6 +20,8 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull /** Draw inside the encoded car display; a normal Android Toast would appear on the phone. */ @Composable @@ -31,7 +33,11 @@ internal fun CarToastOverlay(modifier: Modifier = Modifier) { val timeout = accessibility?.calculateRecommendedTimeoutMillis( message.durationMs, containsIcons = false, containsText = true, containsControls = false, ) ?: message.durationMs - delay(timeout) + // The full time, unless another toast is waiting: then it gives way after the minimum. + delay(minOf(timeout, CarToasts.MIN_SHOWN_MS)) + withTimeoutOrNull((timeout - CarToasts.MIN_SHOWN_MS).coerceAtLeast(0)) { + CarToasts.waiting.first { it > 0 } + } CarToasts.dismiss(message) } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToasts.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToasts.kt index 84b162d..6d3e300 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToasts.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToasts.kt @@ -6,29 +6,60 @@ 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. */ +/** + * One toast at a time across navigation and browse apps. A newer one waits its turn instead of + * replacing the one on screen, which gets at least [MIN_SHOWN_MS] before it gives way. + */ @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) + /** Long enough to read a short toast at a glance; a waiting toast cuts the rest short. */ + const val MIN_SHOWN_MS = 2_000L + + /** A burst beyond this many is stale by the time it would show; the oldest waiting go first. */ + private const val MAX_WAITING = 3 + private val current = MutableStateFlow(null) val message: StateFlow = current.asStateFlow() + private val queue = ArrayDeque() + private val _waiting = MutableStateFlow(0) + val waiting: StateFlow = _waiting.asStateFlow() + fun show(owner: Any, text: String, duration: Int) { if (text.isBlank()) return // Longer than a phone toast: the driver only glances at the screen now and then. val durationMs = if (duration == CarToast.LENGTH_LONG) 7_000L else 4_000L - current.value = Message(owner, text, durationMs) + val next = Message(owner, text, durationMs) + val shown = current.value + when { + shown == null -> current.value = next + // The same words again restart the one on screen rather than queueing a copy. + shown.text == text -> current.value = next + queue.lastOrNull()?.text == text -> Unit + else -> { + queue.addLast(next) + while (queue.size > MAX_WAITING) queue.removeFirst() + } + } + _waiting.value = queue.size } 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 + if (current.value === message) advance() } 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 + queue.removeAll { it.owner === owner } + if (current.value?.owner === owner) advance() else _waiting.value = queue.size + } + + private fun advance() { + current.value = queue.removeFirstOrNull() + _waiting.value = queue.size } } diff --git a/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarToastsTest.kt b/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarToastsTest.kt index f523929..05c8c90 100644 --- a/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarToastsTest.kt +++ b/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarToastsTest.kt @@ -13,15 +13,30 @@ class CarToastsTest { @Before @After fun clearToast() { - CarToasts.message.value?.let(CarToasts::dismiss) + while (true) CarToasts.message.value?.let(CarToasts::dismiss) ?: break } @Test - fun expiredToastCannotDismissItsReplacement() { + fun aNewToastWaitsForTheOneOnScreen() { val owner = Any() CarToasts.show(owner, "First", CarToast.LENGTH_SHORT) val first = requireNotNull(CarToasts.message.value) CarToasts.show(owner, "Second", CarToast.LENGTH_LONG) + + assertSame(first, CarToasts.message.value) + assertEquals(1, CarToasts.waiting.value) + CarToasts.dismiss(first) + assertEquals("Second", CarToasts.message.value?.text) + assertEquals(0, CarToasts.waiting.value) + } + + @Test + fun expiredTimerCannotDismissItsReplacement() { + val owner = Any() + CarToasts.show(owner, "First", CarToast.LENGTH_SHORT) + val first = requireNotNull(CarToasts.message.value) + CarToasts.show(owner, "Second", CarToast.LENGTH_LONG) + CarToasts.dismiss(first) val second = requireNotNull(CarToasts.message.value) CarToasts.dismiss(first) @@ -39,6 +54,7 @@ class CarToastsTest { val repeated = requireNotNull(CarToasts.message.value) assertNotSame(first, repeated) + assertEquals(0, CarToasts.waiting.value) CarToasts.dismiss(first) assertSame(repeated, CarToasts.message.value) } @@ -49,14 +65,24 @@ class CarToastsTest { 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) + assertEquals("Added to playlist", CarToasts.message.value?.text) CarToasts.clear(browse) assertNull(CarToasts.message.value) } + @Test + fun aBurstKeepsOnlyTheNewestFewWaiting() { + val owner = Any() + listOf("1", "2", "3", "4", "5", "6").forEach { CarToasts.show(owner, it, CarToast.LENGTH_SHORT) } + + assertEquals("1", CarToasts.message.value?.text) + assertEquals(3, CarToasts.waiting.value) + CarToasts.message.value?.let(CarToasts::dismiss) + assertEquals("4", CarToasts.message.value?.text) + } + @Test fun blankRequestsDoNotReplaceAVisibleMessage() { val owner = Any() @@ -65,6 +91,7 @@ class CarToastsTest { CarToasts.show(owner, " \n\t", CarToast.LENGTH_LONG) assertSame(visible, CarToasts.message.value) + assertEquals(0, CarToasts.waiting.value) } @Test @@ -72,8 +99,10 @@ class CarToastsTest { val owner = Any() CarToasts.show(owner, "Short", CarToast.LENGTH_SHORT) assertEquals(4_000L, CarToasts.message.value?.durationMs) + CarToasts.message.value?.let(CarToasts::dismiss) CarToasts.show(owner, "Long", CarToast.LENGTH_LONG) assertEquals(7_000L, CarToasts.message.value?.durationMs) + CarToasts.message.value?.let(CarToasts::dismiss) CarToasts.show(owner, "Unknown", Int.MAX_VALUE) assertEquals(4_000L, CarToasts.message.value?.durationMs) }