back buttons on every screen, exit to the car's own UI, smoother link

Every screen reached from Home has a themed back button in its header. A new Exit tile
asks the car to show its own interface, as Android Auto's does. All writes to the car
now go through one thread, so reading never waits on a write; LIVI no longer stalls
for two seconds at a time. Touches go to the front of the main thread's queue.
This commit is contained in:
Seb3thehacker committed 2026-10-04 19:57:43 -07:00
1 parent 1adeb6f7e1
commit 0eeb486d88
13 files changed
+246 -55

No files matched your search

@@ -0,0 +1,28 @@
package app.seb3thehacker.gearslip
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Who has the car's screen: Gearslip, or the car's own interface. Android Auto's "Exit" hands the
* screen back to the car with the session still running, so music keeps playing; the car's own
* Android Auto button brings Gearslip back. [GearslipRunner] fills in [request] while connected.
*/
object CarFocus {
private val projectedFlow = MutableStateFlow(true)
/** False while the car shows its own interface. */
val projected: StateFlow<Boolean> = projectedFlow
@Volatile internal var request: (() -> Unit)? = null
/** Asks the car to show its own interface. Does nothing when no car is connected. */
fun exitToCar() {
request?.invoke()
}
internal fun set(projected: Boolean) {
projectedFlow.value = projected
}
}
@@ -40,6 +40,8 @@ import java.io.FileOutputStream
* rotation or theme change from recreating it and dropping the connection. * rotation or theme change from recreating it and dropping the connection.
*/ */
class GearslipActivity : ComponentActivity(), Projection { class GearslipActivity : ComponentActivity(), Projection {
private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
private var descriptor: ParcelFileDescriptor? = null private var descriptor: ParcelFileDescriptor? = null
private var runner: GearslipRunner? = null private var runner: GearslipRunner? = null
@@ -323,7 +325,9 @@ class GearslipActivity : ComponentActivity(), Projection {
} }
override fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>) { override fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>) {
runOnUiThread { // To the front of the main thread's queue: a touch waiting behind other work is lag the
// driver feels, while the work it jumps ahead of can wait a frame.
mainHandler.postAtFrontOfQueue {
// Mirroring drives one phone touch at a time, so it follows the first finger only. // Mirroring drives one phone touch at a time, so it follows the first finger only.
if (PhoneMirror.active.value) points.firstOrNull()?.let { relayTouch(action, it.x, it.y) } if (PhoneMirror.active.value) points.firstOrNull()?.let { relayTouch(action, it.x, it.y) }
else screenProjector?.dispatchTouch(action, actionIndex, points) else screenProjector?.dispatchTouch(action, actionIndex, points)
@@ -41,6 +41,9 @@ class GearslipRunner(
private val assembler = Frames.Assembler() private val assembler = Frames.Assembler()
private lateinit var identityTask: FutureTask<PhoneTls> private lateinit var identityTask: FutureTask<PhoneTls>
/** Sends every message, in order, so no other thread ever blocks on the USB link. See [send]. */
private val writer = java.util.concurrent.Executors.newSingleThreadExecutor { Thread(it, "gearslip-writer") }
/** Waits for the certificate only when TLS is first needed; see [run]. */ /** Waits for the certificate only when TLS is first needed; see [run]. */
private val tls: PhoneTls by lazy { identityTask.get() } private val tls: PhoneTls by lazy { identityTask.get() }
@@ -127,6 +130,9 @@ class GearslipRunner(
fun stop() { fun stop() {
running = false running = false
writer.shutdownNow()
CarFocus.request = null
CarFocus.set(true)
synchronized(ackLock) { ackLock.notifyAll() } // free an encoder waiting on an ack synchronized(ackLock) { ackLock.notifyAll() } // free an encoder waiting on an ack
videoSource?.stop() videoSource?.stop()
videoSource = null videoSource = null
@@ -589,6 +595,8 @@ class GearslipRunner(
val at = points[actionIndex] val at = points[actionIndex]
log.i("<- Touch action=$action at (${at.x.toInt()},${at.y.toInt()}) pointers=${points.size}") log.i("<- Touch action=$action at (${at.x.toInt()},${at.y.toInt()}) pointers=${points.size}")
} }
// Time the next frame against this tap: see [reportTouchLatency].
if (action == ACTION_DOWN || action == ACTION_UP) touchAtUs = System.nanoTime() / 1000
projection?.onTouch(action, actionIndex, points) projection?.onTouch(action, actionIndex, points)
} }
@@ -691,14 +699,17 @@ class GearslipRunner(
} }
MSG_VIDEO_FOCUS_NOTIFICATION -> { MSG_VIDEO_FOCUS_NOTIFICATION -> {
// VideoFocusNotification { focus = 1; unsolicited = 2 }
val fields = Wire.fields(body) val fields = Wire.fields(body)
log.i("<- VideoFocusNotification: mode=${Wire.varint(fields, 1)}") val mode = Wire.varint(fields, 1)?.toInt() ?: VIDEO_FOCUS_PROJECTED
// The head unit repeats this while it waits for frames; only act once. log.i("<- VideoFocusNotification: mode=$mode unsolicited=${Wire.varint(fields, 2)}")
if (!videoStarted) { onVideoFocus(mode)
videoStarted = true }
log.i("PHASE A COMPLETE - video focus granted; starting video source")
startVideoSource() // Not in aasdk's direction of travel, but LIVI sends it: log what it asks for.
} MSG_VIDEO_FOCUS_REQUEST -> {
val fields = Wire.fields(body)
log.i("<- VideoFocusRequest from the head unit: mode=${Wire.varint(fields, 2)} reason=${Wire.varint(fields, 3)}")
} }
MSG_MEDIA_ACK -> { MSG_MEDIA_ACK -> {
@@ -772,6 +783,7 @@ class GearslipRunner(
frameRate = if (config.frameRate == 1) 60 else 30, frameRate = if (config.frameRate == 1) 60 else 30,
mode = if (useProjection) VideoSource.Mode.SURFACE else VideoSource.Mode.TEST_CARD, mode = if (useProjection) VideoSource.Mode.SURFACE else VideoSource.Mode.TEST_CARD,
onCodecConfig = { csd -> onCodecConfig = { csd ->
lastCodecConfig = csd
send(MSG_MEDIA_CODEC_CONFIG, csd, encrypted = true, channel = videoChannelId) send(MSG_MEDIA_CODEC_CONFIG, csd, encrypted = true, channel = videoChannelId)
log.i("-> CodecConfig (${csd.size} bytes SPS/PPS)") log.i("-> CodecConfig (${csd.size} bytes SPS/PPS)")
log.hex(" csd", csd, limit = 64) log.hex(" csd", csd, limit = 64)
@@ -800,6 +812,8 @@ class GearslipRunner(
* makes it produce fewer frames, each one whole. * makes it produce fewer frames, each one whole.
*/ */
private fun sendVideoFrame(data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) { private fun sendVideoFrame(data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) {
// The car is showing its own screen and would throw these away.
if (!videoProjected) return
synchronized(ackLock) { synchronized(ackLock) {
val waitStart = System.currentTimeMillis() val waitStart = System.currentTimeMillis()
while (running && inFlight >= maxUnacked) { while (running && inFlight >= maxUnacked) {
@@ -809,6 +823,7 @@ class GearslipRunner(
if (since > ACK_STALL_MS) { if (since > ACK_STALL_MS) {
log.w("no ack for ${since}ms with $inFlight in flight - resetting the window") log.w("no ack for ${since}ms with $inFlight in flight - resetting the window")
inFlight = 0 inFlight = 0
lastAckAt = System.currentTimeMillis() // one warning per stall, not one per frame
break break
} }
ackLock.wait(ACK_STALL_MS - since + 1) ackLock.wait(ACK_STALL_MS - since + 1)
@@ -828,15 +843,11 @@ class GearslipRunner(
resyncAttempts = 0 resyncAttempts = 0
lastKeyframeSentAt = System.currentTimeMillis() lastKeyframeSentAt = System.currentTimeMillis()
} }
try { // Counted before it's queued: the writer may send it and the ack come back before this
send(MSG_MEDIA_DATA, timestamp + data, encrypted = true, channel = videoChannelId) // thread runs again, and an ack counted ahead of its frame would leave the window full.
} catch (e: java.io.IOException) { synchronized(ackLock) { inFlight++ }
if (running) log.w("video send failed, stopping the encoder: ${e.message}") send(MSG_MEDIA_DATA, timestamp + data, encrypted = true, channel = videoChannelId)
running = false reportTouchLatency(presentationTimeUs)
videoSource?.stop()
return
}
inFlight++
framesSent++ framesSent++
if (framesSent <= 3 || keyFrame && framesSent % 30 == 0) { if (framesSent <= 3 || keyFrame && framesSent % 30 == 0) {
log.i( log.i(
@@ -846,6 +857,74 @@ class GearslipRunner(
} }
} }
/** Whether the car is showing Gearslip; false while it shows its own interface. */
@Volatile private var videoProjected = true
/** The encoder's SPS/PPS, resent when the car hands its screen back. */
@Volatile private var lastCodecConfig: ByteArray? = null
/**
* The car says who has its screen. The first grant starts the video. After that the car may
* take its screen back (our Exit, or its own buttons) and later return it: while it has the
* screen nothing is sent, and on return the stream restarts from the codec config and a fresh
* keyframe, since the car's decoder may have been reset in between.
*/
private fun onVideoFocus(mode: Int) {
val projected = mode == VIDEO_FOCUS_PROJECTED || mode == VIDEO_FOCUS_PROJECTED_NO_INPUT
if (!videoStarted) {
// The head unit repeats this while it waits for frames; only start once.
if (!projected) return
videoStarted = true
CarFocus.request = ::requestNativeFocus
log.i("PHASE A COMPLETE - video focus granted; starting video source")
startVideoSource()
return
}
if (projected == videoProjected) return
videoProjected = projected
CarFocus.set(projected)
if (projected) {
log.i("the car handed its screen back - restarting the stream")
// Frames sent just before the car took over may never be acked.
synchronized(ackLock) {
inFlight = 0
ackLock.notifyAll()
}
lastCodecConfig?.let { send(MSG_MEDIA_CODEC_CONFIG, it, encrypted = true, channel = videoChannelId) }
videoSource?.requestSyncFrame()
} else {
log.i("the car is showing its own screen - video paused, audio carries on")
}
}
/** Android Auto's Exit: asks the car for its own interface, leaving the session running. */
private fun requestNativeFocus() {
if (!videoStarted) return
// VideoFocusRequestNotification { mode = 2; reason = 3 }
val request = Protobuf.varintField(2, VIDEO_FOCUS_NATIVE.toLong()) +
Protobuf.varintField(3, VIDEO_FOCUS_REASON_LAUNCH_NATIVE.toLong())
send(MSG_VIDEO_FOCUS_REQUEST, request, encrypted = true, channel = videoChannelId)
log.i("-> VideoFocusRequest(mode=NATIVE, reason=LAUNCH_NATIVE)")
}
/** Arrival time of the last tap still waiting to be seen in a frame; 0 when none is. */
@Volatile private var touchAtUs = 0L
/**
* How long a tap takes to reach the car, split where the phone can see it: the UI drawing a
* frame after the tap, the encoder compressing it, and the send. A frame's timestamp is when
* the UI finished drawing it, on the same clock as [touchAtUs]. The head unit's own decode
* and display come on top and can't be seen from here.
*/
private fun reportTouchLatency(presentationTimeUs: Long) {
val touchAt = touchAtUs
if (touchAt == 0L || presentationTimeUs <= touchAt) return
touchAtUs = 0L
val nowUs = System.nanoTime() / 1000
val draw = (presentationTimeUs - touchAt) / 1000
val encode = (nowUs - presentationTimeUs) / 1000
log.i("touch -> frame: ${draw + encode}ms (draw ${draw}ms, encode and wait ${encode}ms)")
}
private fun clamp(value: Int, limit: Int?): Float = when { private fun clamp(value: Int, limit: Int?): Float = when {
limit == null -> value.toFloat() limit == null -> value.toFloat()
value < 0 -> 0f value < 0 -> 0f
@@ -872,35 +951,49 @@ class GearslipRunner(
((messageId shr 8) and 0xFF).toByte(), (messageId and 0xFF).toByte(), ((messageId shr 8) and 0xFF).toByte(), (messageId and 0xFF).toByte(),
) + body ) + body
synchronized(output) { // Every write goes through one thread, in order. The reading thread must never wait on
if (payload.size <= Frames.MAX_FRAME_PAYLOAD) { // a write: a head unit like LIVI writes its ack before it reads on, so a reader stuck
val framePayload = if (encrypted) tls.encrypt(payload) else payload // behind a big video frame (to answer a ping) and a head unit stuck writing that ack
output.write(Frames.build(channel, encrypted, framePayload, messageType)) // held each other up until something timed out, about two seconds a time.
} else { try {
// Split exactly as aasdk does: chunk the *plaintext*, encrypt each chunk writer.execute {
// separately, and carry the total plaintext length in the FIRST frame. try {
// Video keyframes for anything busier than a test card exceed 16 KB routinely. if (payload.size <= Frames.MAX_FRAME_PAYLOAD) {
var offset = 0 val framePayload = if (encrypted) tls.encrypt(payload) else payload
while (offset < payload.size) { output.write(Frames.build(channel, encrypted, framePayload, messageType))
val size = minOf(Frames.MAX_FRAME_PAYLOAD, payload.size - offset) } else {
val chunk = payload.copyOfRange(offset, offset + size) // Split exactly as aasdk does: chunk the *plaintext*, encrypt each chunk
val frameType = when { // separately, and carry the total plaintext length in the FIRST frame.
offset == 0 -> Frames.TYPE_FIRST // Video keyframes for anything busier than a test card exceed 16 KB routinely.
offset + size >= payload.size -> Frames.TYPE_LAST var offset = 0
else -> Frames.TYPE_MIDDLE while (offset < payload.size) {
val size = minOf(Frames.MAX_FRAME_PAYLOAD, payload.size - offset)
val chunk = payload.copyOfRange(offset, offset + size)
val frameType = when {
offset == 0 -> Frames.TYPE_FIRST
offset + size >= payload.size -> Frames.TYPE_LAST
else -> Frames.TYPE_MIDDLE
}
val framePayload = if (encrypted) tls.encrypt(chunk) else chunk
output.write(
Frames.build(
channel, encrypted, framePayload, messageType, frameType,
if (frameType == Frames.TYPE_FIRST) payload.size else null,
),
)
offset += size
}
splitMessages++
} }
val framePayload = if (encrypted) tls.encrypt(chunk) else chunk output.flush()
output.write( } catch (e: java.io.IOException) {
Frames.build( if (running) log.w("send failed, stopping: ${e.message}")
channel, encrypted, framePayload, messageType, frameType, running = false
if (frameType == Frames.TYPE_FIRST) payload.size else null, videoSource?.stop()
),
)
offset += size
} }
splitMessages++
} }
output.flush() } catch (e: java.util.concurrent.RejectedExecutionException) {
// The session is over; nothing more goes out.
} }
} }
@@ -1039,6 +1132,9 @@ class GearslipRunner(
const val STREAM_MEDIA = 3 const val STREAM_MEDIA = 3
const val CODEC_H264_BP = 3 const val CODEC_H264_BP = 3
const val VIDEO_FOCUS_PROJECTED = 1 const val VIDEO_FOCUS_PROJECTED = 1
const val VIDEO_FOCUS_NATIVE = 2
const val VIDEO_FOCUS_PROJECTED_NO_INPUT = 4
const val VIDEO_FOCUS_REASON_LAUNCH_NATIVE = 2
const val SESSION_ID = 1 const val SESSION_ID = 1
const val MSG_INPUT_REPORT = 32769 const val MSG_INPUT_REPORT = 32769
const val MSG_KEY_BINDING_REQUEST = 32770 const val MSG_KEY_BINDING_REQUEST = 32770
@@ -60,7 +60,7 @@ fun CarDashboardScreen() {
.padding(horizontal = 24.dp, vertical = 16.dp), .padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp),
) { ) {
Text("Today", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) ScreenHeader("Today")
WeatherCard(context) WeatherCard(context)
AgendaCard(context) AgendaCard(context)
NotificationsSection() NotificationsSection()
@@ -167,7 +167,7 @@ internal fun rememberAllEntries(): List<Entry> {
} }
/** The launcher's groups, in the order they're laid out. */ /** The launcher's groups, in the order they're laid out. */
private enum class AppGroup { PHONE, MAPS, MUSIC, MESSAGING, WEB, SCREEN_SHARING, OTHER, SETTINGS } private enum class AppGroup { PHONE, MAPS, MUSIC, MESSAGING, WEB, SCREEN_SHARING, OTHER, SETTINGS, EXIT }
private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup { private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
entry.builtIn?.let { entry.builtIn?.let {
@@ -175,6 +175,7 @@ private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
BuiltInApps.Phone -> AppGroup.PHONE BuiltInApps.Phone -> AppGroup.PHONE
BuiltInApps.Web -> AppGroup.WEB BuiltInApps.Web -> AppGroup.WEB
BuiltInApps.ScreenSharing -> AppGroup.SCREEN_SHARING BuiltInApps.ScreenSharing -> AppGroup.SCREEN_SHARING
BuiltInApps.Exit -> AppGroup.EXIT
else -> AppGroup.SETTINGS else -> AppGroup.SETTINGS
} }
} }
@@ -74,7 +74,7 @@ fun CarSettingsScreen() {
.padding(horizontal = 24.dp, vertical = 16.dp), .padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp), verticalArrangement = Arrangement.spacedBy(20.dp),
) { ) {
Text("Settings", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) ScreenHeader("Settings")
ChoiceRow( ChoiceRow(
"Open on connect", "Open on connect",
@@ -40,11 +40,26 @@ import app.seb3thehacker.gearslip.car.theme.GsButton
import app.seb3thehacker.gearslip.car.theme.GsTone import app.seb3thehacker.gearslip.car.theme.GsTone
import app.seb3thehacker.gearslip.host.CarAppConnection import app.seb3thehacker.gearslip.host.CarAppConnection
import app.seb3thehacker.gearslip.media.CarMedia import app.seb3thehacker.gearslip.media.CarMedia
import app.seb3thehacker.gearslip.CarFocus
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.PathParser
/** What a built-in app's id carries in a pin, so it can't collide with an installed app's component. */ /** What a built-in app's id carries in a pin, so it can't collide with an installed app's component. */
internal const val BUILT_IN_PREFIX = "gearslip:" internal const val BUILT_IN_PREFIX = "gearslip:"
/** One of Gearslip's own apps: a screen, with nothing running behind it once you leave. */ /** One of Gearslip's own apps: a screen, with nothing running behind it once you leave. */
/** Material's "directions car", drawn here because only the core icon set is bundled. */
private val CarGlyph: ImageVector = ImageVector.Builder("Car", 24.dp, 24.dp, 24f, 24f).addPath(
PathParser().parsePathString(
"M18.92,6.01C18.72,5.42 18.16,5 17.5,5h-11c-0.66,0 -1.21,0.42 -1.42,1.01L3,12v8c0,0.55 0.45,1 1,1h1" +
"c0.55,0 1,-0.45 1,-1v-1h12v1c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-8l-2.08,-5.99z" +
"M6.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5S5.67,13 6.5,13s1.5,0.67 1.5,1.5S7.33,16 6.5,16z" +
"M17.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5z" +
"M5,11l1.5,-4.5h11L19,11L5,11z",
).toNodes(),
fill = SolidColor(Color.Black),
).build()
internal class BuiltInApp( internal class BuiltInApp(
val id: String, val id: String,
val label: String, val label: String,
@@ -60,7 +75,10 @@ internal object BuiltInApps {
val VehicleData = BuiltInApp("vehicle", "Vehicle data", Icons.Filled.Info, CarScreen.VehicleData) { it.vehicleData() } val VehicleData = BuiltInApp("vehicle", "Vehicle data", Icons.Filled.Info, CarScreen.VehicleData) { it.vehicleData() }
val Settings = BuiltInApp("settings", "Settings", Icons.Filled.Settings, CarScreen.Settings) { it.settings() } val Settings = BuiltInApp("settings", "Settings", Icons.Filled.Settings, CarScreen.Settings) { it.settings() }
val all = listOf(Web, ScreenSharing, Phone, VehicleData, Settings) /** Hands the screen back to the car's own interface, like Android Auto's Exit. */
val Exit = BuiltInApp("exit", "Exit", CarGlyph, CarScreen.Home) { CarFocus.exitToCar() }
val all = listOf(Web, ScreenSharing, Phone, VehicleData, Settings, Exit)
val entries: List<Entry> = all.map { Entry(it.label, icon = null, builtIn = it) } val entries: List<Entry> = all.map { Entry(it.label, icon = null, builtIn = it) }
} }
@@ -53,6 +53,8 @@ fun PhoneAppsScreen() {
} }
Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) { Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) {
ScreenHeader("Screen sharing")
Spacer(Modifier.height(12.dp))
if (!TouchRelayService.enabled) { if (!TouchRelayService.enabled) {
TouchRelayWarning() TouchRelayWarning()
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
@@ -101,13 +101,7 @@ fun PhoneDialerScreen() {
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
// The tabs share the title's row, so the list and keypad get the height back. // The tabs share the title's row, so the list and keypad get the height back.
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { ScreenHeader("Phone") {
Text(
"Phone",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
if (hasPermissions) { if (hasPermissions) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
DialerTab("Contacts", tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS } DialerTab("Contacts", tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS }
@@ -0,0 +1,43 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.car.theme.GsIconButton
/**
* The top of every screen reached from Home: a back button in the current theme, the title, and
* room on the right for the screen's own controls. A car touchscreen has no hardware back button,
* so this is the way out of a screen.
*/
@Composable
fun ScreenHeader(title: String, modifier: Modifier = Modifier, trailing: @Composable RowScope.() -> Unit = {}) {
val navigator = LocalCarNavigator.current
Row(
modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back)
Text(
title,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
trailing()
}
}
@@ -28,7 +28,7 @@ fun VehicleDataScreen() {
val readings by CarSensors.readings.collectAsState() val readings by CarSensors.readings.collectAsState()
Column(Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp)) { Column(Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp)) {
Text("Vehicle data", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) ScreenHeader("Vehicle data")
Text( Text(
"Raw sensor channel from the connected head unit. Field meaning is a best guess - " + "Raw sensor channel from the connected head unit. Field meaning is a best guess - " +
"watch a row change while you do the thing (brake, drive, headlights) to confirm it.", "watch a row change while you do the thing (brake, drive, headlights) to confirm it.",
@@ -41,6 +41,7 @@ fun WeatherScreen() {
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 16.dp), Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp), verticalArrangement = Arrangement.spacedBy(20.dp),
) { ) {
ScreenHeader("Weather")
when (val s = state) { when (val s = state) {
WeatherState.Loading -> Text("Getting the weather…", style = MaterialTheme.typography.titleMedium) WeatherState.Loading -> Text("Getting the weather…", style = MaterialTheme.typography.titleMedium)
is WeatherState.Unavailable -> Text( is WeatherState.Unavailable -> Text(
@@ -10,6 +10,8 @@ import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -53,6 +55,8 @@ fun WebApp(content: String) {
} }
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
// Back steps through the page's history first (the interceptor above), then leaves.
ScreenHeader("Web", Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
AndroidView( AndroidView(
modifier = Modifier.weight(1f).fillMaxSize(), modifier = Modifier.weight(1f).fillMaxSize(),
factory = { context -> factory = { context ->