diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToastOverlay.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToastOverlay.kt new file mode 100644 index 0000000..c2dab2a --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToastOverlay.kt @@ -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, + ) + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToasts.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToasts.kt new file mode 100644 index 0000000..fc8d569 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToasts.kt @@ -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(null) + val message: StateFlow = 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 + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt index 261bcfd..6bdf7c0 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt @@ -226,6 +226,7 @@ fun CarUi() { } } } + CarToastOverlay(Modifier.align(Alignment.BottomCenter)) PopupTimer() // The dashboard is already listing them, so it needs no popup. NotificationPopup( diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt index d13c3e8..c5f5505 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt @@ -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.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) } diff --git a/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarToastsTest.kt b/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarToastsTest.kt new file mode 100644 index 0000000..a25fef1 --- /dev/null +++ b/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarToastsTest.kt @@ -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) + } +}