diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/CarFocus.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/CarFocus.kt new file mode 100644 index 0000000..a5a4c3b --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/CarFocus.kt @@ -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 = 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 + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt index 8922ceb..ec403c1 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt @@ -40,6 +40,8 @@ import java.io.FileOutputStream * rotation or theme change from recreating it and dropping the connection. */ class GearslipActivity : ComponentActivity(), Projection { + private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper()) + private var descriptor: ParcelFileDescriptor? = null private var runner: GearslipRunner? = null @@ -323,7 +325,9 @@ class GearslipActivity : ComponentActivity(), Projection { } override fun onTouch(action: Int, actionIndex: Int, points: List) { - 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. if (PhoneMirror.active.value) points.firstOrNull()?.let { relayTouch(action, it.x, it.y) } else screenProjector?.dispatchTouch(action, actionIndex, points) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt index 32329d5..d6914ad 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt @@ -41,6 +41,9 @@ class GearslipRunner( private val assembler = Frames.Assembler() private lateinit var identityTask: FutureTask + /** 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]. */ private val tls: PhoneTls by lazy { identityTask.get() } @@ -127,6 +130,9 @@ class GearslipRunner( fun stop() { running = false + writer.shutdownNow() + CarFocus.request = null + CarFocus.set(true) synchronized(ackLock) { ackLock.notifyAll() } // free an encoder waiting on an ack videoSource?.stop() videoSource = null @@ -589,6 +595,8 @@ class GearslipRunner( val at = points[actionIndex] 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) } @@ -691,14 +699,17 @@ class GearslipRunner( } MSG_VIDEO_FOCUS_NOTIFICATION -> { + // VideoFocusNotification { focus = 1; unsolicited = 2 } val fields = Wire.fields(body) - log.i("<- VideoFocusNotification: mode=${Wire.varint(fields, 1)}") - // The head unit repeats this while it waits for frames; only act once. - if (!videoStarted) { - videoStarted = true - log.i("PHASE A COMPLETE - video focus granted; starting video source") - startVideoSource() - } + val mode = Wire.varint(fields, 1)?.toInt() ?: VIDEO_FOCUS_PROJECTED + log.i("<- VideoFocusNotification: mode=$mode unsolicited=${Wire.varint(fields, 2)}") + onVideoFocus(mode) + } + + // 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 -> { @@ -772,6 +783,7 @@ class GearslipRunner( frameRate = if (config.frameRate == 1) 60 else 30, mode = if (useProjection) VideoSource.Mode.SURFACE else VideoSource.Mode.TEST_CARD, onCodecConfig = { csd -> + lastCodecConfig = csd send(MSG_MEDIA_CODEC_CONFIG, csd, encrypted = true, channel = videoChannelId) log.i("-> CodecConfig (${csd.size} bytes SPS/PPS)") log.hex(" csd", csd, limit = 64) @@ -800,6 +812,8 @@ class GearslipRunner( * makes it produce fewer frames, each one whole. */ 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) { val waitStart = System.currentTimeMillis() while (running && inFlight >= maxUnacked) { @@ -809,6 +823,7 @@ class GearslipRunner( if (since > ACK_STALL_MS) { log.w("no ack for ${since}ms with $inFlight in flight - resetting the window") inFlight = 0 + lastAckAt = System.currentTimeMillis() // one warning per stall, not one per frame break } ackLock.wait(ACK_STALL_MS - since + 1) @@ -828,15 +843,11 @@ class GearslipRunner( resyncAttempts = 0 lastKeyframeSentAt = System.currentTimeMillis() } - try { - send(MSG_MEDIA_DATA, timestamp + data, encrypted = true, channel = videoChannelId) - } catch (e: java.io.IOException) { - if (running) log.w("video send failed, stopping the encoder: ${e.message}") - running = false - videoSource?.stop() - return - } - inFlight++ + // Counted before it's queued: the writer may send it and the ack come back before this + // thread runs again, and an ack counted ahead of its frame would leave the window full. + synchronized(ackLock) { inFlight++ } + send(MSG_MEDIA_DATA, timestamp + data, encrypted = true, channel = videoChannelId) + reportTouchLatency(presentationTimeUs) framesSent++ if (framesSent <= 3 || keyFrame && framesSent % 30 == 0) { 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 { limit == null -> value.toFloat() value < 0 -> 0f @@ -872,35 +951,49 @@ class GearslipRunner( ((messageId shr 8) and 0xFF).toByte(), (messageId and 0xFF).toByte(), ) + body - synchronized(output) { - if (payload.size <= Frames.MAX_FRAME_PAYLOAD) { - val framePayload = if (encrypted) tls.encrypt(payload) else payload - output.write(Frames.build(channel, encrypted, framePayload, messageType)) - } else { - // Split exactly as aasdk does: chunk the *plaintext*, encrypt each chunk - // separately, and carry the total plaintext length in the FIRST frame. - // Video keyframes for anything busier than a test card exceed 16 KB routinely. - var offset = 0 - 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 + // Every write goes through one thread, in order. The reading thread must never wait on + // a write: a head unit like LIVI writes its ack before it reads on, so a reader stuck + // behind a big video frame (to answer a ping) and a head unit stuck writing that ack + // held each other up until something timed out, about two seconds a time. + try { + writer.execute { + try { + if (payload.size <= Frames.MAX_FRAME_PAYLOAD) { + val framePayload = if (encrypted) tls.encrypt(payload) else payload + output.write(Frames.build(channel, encrypted, framePayload, messageType)) + } else { + // Split exactly as aasdk does: chunk the *plaintext*, encrypt each chunk + // separately, and carry the total plaintext length in the FIRST frame. + // Video keyframes for anything busier than a test card exceed 16 KB routinely. + var offset = 0 + 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.write( - Frames.build( - channel, encrypted, framePayload, messageType, frameType, - if (frameType == Frames.TYPE_FIRST) payload.size else null, - ), - ) - offset += size + output.flush() + } catch (e: java.io.IOException) { + if (running) log.w("send failed, stopping: ${e.message}") + running = false + videoSource?.stop() } - 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 CODEC_H264_BP = 3 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 MSG_INPUT_REPORT = 32769 const val MSG_KEY_BINDING_REQUEST = 32770 diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarDashboardScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarDashboardScreen.kt index 1f398be..d79b3d3 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarDashboardScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarDashboardScreen.kt @@ -60,7 +60,7 @@ fun CarDashboardScreen() { .padding(horizontal = 24.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(24.dp), ) { - Text("Today", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + ScreenHeader("Today") WeatherCard(context) AgendaCard(context) NotificationsSection() diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt index 1d253c7..6669c07 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt @@ -167,7 +167,7 @@ internal fun rememberAllEntries(): List { } /** 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): AppGroup { entry.builtIn?.let { @@ -175,6 +175,7 @@ private fun groupOf(entry: Entry, mediaPackages: Set): AppGroup { BuiltInApps.Phone -> AppGroup.PHONE BuiltInApps.Web -> AppGroup.WEB BuiltInApps.ScreenSharing -> AppGroup.SCREEN_SHARING + BuiltInApps.Exit -> AppGroup.EXIT else -> AppGroup.SETTINGS } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt index a3a029f..fcfce00 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt @@ -74,7 +74,7 @@ fun CarSettingsScreen() { .padding(horizontal = 24.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(20.dp), ) { - Text("Settings", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + ScreenHeader("Settings") ChoiceRow( "Open on connect", diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt index 85b8898..139d044 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt @@ -40,11 +40,26 @@ import app.seb3thehacker.gearslip.car.theme.GsButton import app.seb3thehacker.gearslip.car.theme.GsTone import app.seb3thehacker.gearslip.host.CarAppConnection 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. */ internal const val BUILT_IN_PREFIX = "gearslip:" /** 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( val id: 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 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 = all.map { Entry(it.label, icon = null, builtIn = it) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneAppsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneAppsScreen.kt index 3c80b1d..b828c1b 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneAppsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneAppsScreen.kt @@ -53,6 +53,8 @@ fun PhoneAppsScreen() { } Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) { + ScreenHeader("Screen sharing") + Spacer(Modifier.height(12.dp)) if (!TouchRelayService.enabled) { TouchRelayWarning() Spacer(Modifier.height(12.dp)) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt index bf16606..cb21b59 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt @@ -101,13 +101,7 @@ fun PhoneDialerScreen() { 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. - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text( - "Phone", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f), - ) + ScreenHeader("Phone") { if (hasPermissions) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { DialerTab("Contacts", tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ScreenHeader.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ScreenHeader.kt new file mode 100644 index 0000000..6576a87 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ScreenHeader.kt @@ -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() + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/VehicleDataScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/VehicleDataScreen.kt index a05cce0..f3317de 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/VehicleDataScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/VehicleDataScreen.kt @@ -28,7 +28,7 @@ fun VehicleDataScreen() { val readings by CarSensors.readings.collectAsState() Column(Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp)) { - Text("Vehicle data", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + ScreenHeader("Vehicle data") Text( "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.", diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WeatherScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WeatherScreen.kt index 4b23d63..15ef3c0 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WeatherScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WeatherScreen.kt @@ -41,6 +41,7 @@ fun WeatherScreen() { Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(20.dp), ) { + ScreenHeader("Weather") when (val s = state) { WeatherState.Loading -> Text("Getting the weather…", style = MaterialTheme.typography.titleMedium) is WeatherState.Unavailable -> Text( 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 9169e55..868fbb7 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt @@ -10,6 +10,8 @@ import android.webkit.WebView import android.webkit.WebViewClient 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.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue @@ -53,6 +55,8 @@ fun WebApp(content: String) { } 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( modifier = Modifier.weight(1f).fillMaxSize(), factory = { context ->