mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
Merge pull request #11 from iscle/feature/host-toasts
Show app toasts on the projected car display
This commit is contained in:
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user