diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ScreenProjector.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ScreenProjector.kt index 7a29fc6..c9ffee5 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ScreenProjector.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ScreenProjector.kt @@ -198,6 +198,12 @@ class ScreenProjector(private val context: Context) { */ fun dispatchKey(keyCode: Int, down: Boolean) { val target = root ?: return + // Keys handed straight to the view skip the step where Android leaves touch mode, and in + // touch mode Compose's buttons refuse focus, so the knob had nothing to move between. + if (target.isInTouchMode) { + target.requestFocusFromTouch() + GearslipLog.i("keys: left touch mode for the knob/D-pad (now in touch mode: ${target.isInTouchMode})") + } // Nothing on the display takes keys until something in it holds focus. if (target.findFocus() == null) target.requestFocus() val shift = keyCode == app.seb3thehacker.gearslip.car.CarKeys.KEYCODE_SHIFT_TAB diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeys.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeys.kt index 3eeae41..ed4b4f0 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeys.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeys.kt @@ -27,6 +27,11 @@ object CarKeys { private val _screenKeys = MutableSharedFlow(extraBufferCapacity = 8) val screenKeys: SharedFlow = _screenKeys + private val _focusMoves = MutableSharedFlow(extraBufferCapacity = 1) + + /** A knob turn or D-pad press, so controls that hide when left alone can come back. */ + val focusMoves: SharedFlow = _focusMoves + /** Delivers an Android key event to the car display; set while the car UI is showing. */ @Volatile var focusSink: ((keyCode: Int, down: Boolean) -> Unit)? = null @@ -58,6 +63,7 @@ object CarKeys { */ fun onKey(keyCode: Int, down: Boolean, longPress: Boolean): Boolean { if (keyCode in FOCUS_KEYS) { + _focusMoves.tryEmit(Unit) focusSink?.invoke(keyCode, down) return true } @@ -86,6 +92,7 @@ object CarKeys { /** A turn of the rotary knob: each click moves focus one step, like Tab or Shift+Tab. */ fun onRotate(delta: Int) { val sink = focusSink ?: return + _focusMoves.tryEmit(Unit) val code = if (delta > 0) KeyEvent.KEYCODE_TAB else KEYCODE_SHIFT_TAB repeat(kotlin.math.abs(delta).coerceAtMost(MAX_ROTARY_STEPS)) { sink(code, true) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt index 5f02ea4..6c6d920 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt @@ -127,6 +127,19 @@ object CarServices { if (CarSettings.pipeAudio.value) CarAudio.request() } + /** + * Reopens the media app used last, for the car's media button when nothing is open. False + * when there's none on record or it's been uninstalled since. + */ + suspend fun reopenLastMedia(): Boolean { + val ctx = context ?: return false + val last = CarSettings.lastMedia.value?.let(ComponentName::unflattenFromString) ?: return false + val app = withContext(Dispatchers.IO) { MediaCatalog.installed(ctx) } + .firstOrNull { it.component == last } ?: return false + openMedia(app) + return true + } + /** Closes the media app from the car: stops what it's playing and drops the connection. */ fun closeMedia() { if (media.now.value.playing) media.togglePlay() 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 c21f6e2..d776d49 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt @@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.car import androidx.compose.foundation.Image import androidx.compose.foundation.clickable +import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -120,6 +121,8 @@ fun CarUi() { controlsWake.tick() } } + // The knob counts as a touch: the map's buttons can't be found while they're faded out. + LaunchedEffect(controlsWake) { CarKeys.focusMoves.collect { controlsWake.touch() } } remember(appContext) { Prefetch.warm(appContext, force = true); CarServices.init(appContext) } DisposableEffect(Unit) { onDispose { CarServices.shutdown() } } // Ask for the phone's sound as soon as the car connects, not only when a media app opens: @@ -152,8 +155,12 @@ fun CarUi() { VoiceReply.state.value.phase != VoiceReply.Phase.IDLE -> VoiceReply.cancel() else -> navigator.back() } - CarKeys.ScreenKey.HOME, CarKeys.ScreenKey.MAP -> navigator.home() - CarKeys.ScreenKey.MEDIA -> if (CarServices.mediaApp.value != null) navigator.media() else navigator.apps() + // Home is the dashboard, over the map, so Back from it lands on the map. + CarKeys.ScreenKey.HOME -> { navigator.home(); navigator.dashboard() } + CarKeys.ScreenKey.MAP -> navigator.home() + CarKeys.ScreenKey.MEDIA -> + if (CarServices.mediaApp.value != null || CarServices.reopenLastMedia()) navigator.media() + else navigator.apps() CarKeys.ScreenKey.PHONE -> BuiltInApps.Phone.open(navigator) } } @@ -167,6 +174,7 @@ fun CarUi() { LocalGsTheme provides GsThemes.byId(uiThemeId), ) { MaterialTheme(colorScheme = colors) { + CompositionLocalProvider(LocalIndication provides FocusRingIndication(MaterialTheme.colorScheme.primary)) { Surface( Modifier .fillMaxSize() @@ -256,6 +264,7 @@ fun CarUi() { if (safetyWarningSeen) CarWhatsNew() } } + } } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/FocusRing.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/FocusRing.kt new file mode 100644 index 0000000..bc063c5 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/FocusRing.kt @@ -0,0 +1,84 @@ +package app.seb3thehacker.gearslip.car + +import androidx.compose.foundation.IndicationNodeFactory +import androidx.compose.foundation.interaction.FocusInteraction +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.material3.ripple +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.invalidateDraw +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch + +/** + * What every car button shows: the usual ripple when pressed, and a ring in the accent color + * while the knob or D-pad is on it. Cars without a knob never see the ring, since nothing takes + * focus while the screen is in touch mode. + * + * It replaces the theme's ripple (LocalIndication) for the whole car UI, so any `clickable` that + * doesn't pass its own indication gets the ring for free. + */ +internal class FocusRingIndication(private val color: Color) : IndicationNodeFactory { + private val ripple = ripple() + + override fun create(interactionSource: InteractionSource): DelegatableNode = + FocusRingNode(ripple.create(interactionSource), interactionSource, color) + + override fun equals(other: Any?) = other is FocusRingIndication && other.color == color + override fun hashCode() = color.hashCode() +} + +private class FocusRingNode( + rippleNode: DelegatableNode, + private val source: InteractionSource, + private val color: Color, +) : DelegatingNode(), DrawModifierNode { + private var focused = false + + init { delegate(rippleNode) } + + override fun onAttach() { + coroutineScope.launch { + var holding = 0 + source.interactions.collect { + when (it) { + is FocusInteraction.Focus -> holding++ + is FocusInteraction.Unfocus -> holding-- + } + val now = holding > 0 + if (now != focused) { + focused = now + invalidateDraw() + } + } + } + } + + override fun ContentDrawScope.draw() { + drawContent() + if (!focused) return + // Inside the button's bounds, since most buttons clip to their shape: drawn outside, a + // rounded tile would cut its own ring off. + val width = RING_WIDTH.toPx() + val radius = minOf(CORNER.toPx(), size.minDimension / 2) + drawRoundRect( + color, + topLeft = Offset(width / 2, width / 2), + size = Size(size.width - width, size.height - width), + cornerRadius = CornerRadius(radius), + style = Stroke(width), + ) + } + + private companion object { + val RING_WIDTH = 3.dp + val CORNER = 16.dp + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt index cee1973..1111404 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt @@ -3,6 +3,7 @@ package app.seb3thehacker.gearslip.car import app.seb3thehacker.gearslip.car.theme.* import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.material3.LocalContentColor import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.luminance @@ -233,7 +234,9 @@ internal fun RightEdgeControls( // the first touch on one wakes them rather than pressing it. val awake = LocalMapControlsWake.current.awake val pan by CarServices.nav.panMode.collectAsState() - val visible = awake || pan + // Kept up while the knob is on one of them, or the ring would fade out along with the button. + var holdsFocus by remember { mutableStateOf(false) } + val visible = awake || pan || holdsFocus val alpha by androidx.compose.animation.core.animateFloatAsState( targetValue = if (visible) 1f else 0f, animationSpec = androidx.compose.animation.core.tween(if (visible) 150 else 500), @@ -242,6 +245,7 @@ internal fun RightEdgeControls( Column( modifier .alpha(alpha) + .onFocusChanged { holdsFocus = it.hasFocus } .let { base -> if (visible) base else base.pointerInput(Unit) { awaitPointerEventScope { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt index 868fbb7..7883fc1 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.unit.dp import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue @@ -19,6 +20,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.viewinterop.AndroidView import app.seb3thehacker.gearslip.GearslipLog import org.json.JSONObject @@ -38,6 +40,7 @@ import org.json.JSONObject fun WebApp(content: String) { val navigator = LocalCarNavigator.current val darkOutside = LocalDarkOutside.current + val ring = String.format("#%06X", MaterialTheme.colorScheme.primary.toArgb() and 0xFFFFFF) var web by remember { mutableStateOf(null) } var fieldText by remember { mutableStateOf(null) } @@ -65,7 +68,7 @@ fun WebApp(content: String) { settings.domStorageEnabled = true settings.loadWithOverviewMode = true settings.useWideViewPort = true - webViewClient = LoggingClient() + webViewClient = LoggingClient(ring) addJavascriptInterface( FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = null }), "GearslipField", @@ -99,10 +102,11 @@ fun WebApp(content: String) { } } -private class LoggingClient : WebViewClient() { +private class LoggingClient(private val ring: String) : WebViewClient() { override fun onPageFinished(view: WebView?, url: String?) { GearslipLog.i("car web page loaded: $url") view?.evaluateJavascript(FOCUS_TRACKING_JS, null) + view?.evaluateJavascript(focusRingJs(ring), null) } // onPageFinished fires for error pages too, so without this a blocked load (missing @@ -170,6 +174,20 @@ private const val SUBMIT_FIELD_JS = """ })(); """ +/** + * The same ring the rest of the car UI shows, on whatever link or field the knob is on. Many + * sites hide the browser's own focus outline, which leaves a knob driver with no idea where they are. + */ +private fun focusRingJs(color: String) = """ + (function() { + if (document.getElementById('gearslip-focus-ring')) return; + var style = document.createElement('style'); + style.id = 'gearslip-focus-ring'; + style.textContent = ':focus-visible { outline: 3px solid $color !important; outline-offset: 2px !important; }'; + (document.head || document.documentElement).appendChild(style); + })(); +""" + /** Reports focus/blur on text inputs, textareas and contenteditable elements to [FieldFocusBridge]. */ private const val FOCUS_TRACKING_JS = """ (function() {