The cockpit shows the navigation app's own map beside the turn card, for apps with a cluster session

This commit is contained in:
Seb3thehacker committed 2026-11-05 14:46:50 -08:00
1 parent 2a14c780fa
commit 163e206715
3 files changed
+117 -5

No files matched your search

@@ -168,7 +168,7 @@ internal class ClusterVideo(
projector = ScreenProjector(context).also { projector = ScreenProjector(context).also {
it.start(surface, width, height, density, name = "gearslip-cluster") { it.start(surface, width, height, density, name = "gearslip-cluster") {
// The margins the car crops off are kept clear, as on the main screen. // The margins the car crops off are kept clear, as on the main screen.
ClusterScreen(config.widthMargin, config.heightMargin, clearArea) ClusterScreen(config.widthMargin, config.heightMargin, density, clearArea)
} }
} }
} }
@@ -1,11 +1,20 @@
package app.seb3thehacker.gearslip.car package app.seb3thehacker.gearslip.car
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.view.SurfaceHolder
import android.view.SurfaceView
import androidx.car.app.CarAppService
import androidx.car.app.SessionInfo
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -16,18 +25,25 @@ import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import app.seb3thehacker.gearslip.Insets import app.seb3thehacker.gearslip.Insets
import app.seb3thehacker.gearslip.host.CarAppConnection
import app.seb3thehacker.gearslip.host.TemplateApp
import app.seb3thehacker.gearslip.host.clockTime import app.seb3thehacker.gearslip.host.clockTime
import app.seb3thehacker.gearslip.host.display import app.seb3thehacker.gearslip.host.display
import app.seb3thehacker.gearslip.host.remainingTime import app.seb3thehacker.gearslip.host.remainingTime
@@ -43,10 +59,22 @@ import kotlinx.coroutines.flow.StateFlow
* the frame, kept clear. Many cars also draw their own dials and bars over what's left, so the * the frame, kept clear. Many cars also draw their own dials and bars over what's left, so the
* card keeps to [clearArea] when the car says where that is, and otherwise to the middle of a * card keeps to [clearArea] when the car says where that is, and otherwise to the middle of a
* wide strip: on an Audi's virtual cockpit it ran under the rev counter and the bottom bar (#56). * wide strip: on an Audi's virtual cockpit it ran under the rev counter and the bottom bar (#56).
*
* When the main screen's navigation app has a session for clusters, it draws its map to the
* right of the card, on a surface Gearslip lends that session as it does the main one.
*/ */
@Composable @Composable
internal fun ClusterScreen(marginX: Int, marginY: Int, clearArea: StateFlow<Insets?>) { internal fun ClusterScreen(marginX: Int, marginY: Int, densityDpi: Int, clearArea: StateFlow<Insets?>) {
val carInsets by clearArea.collectAsState() val carInsets by clearArea.collectAsState()
val context = LocalContext.current
// Which app draws the map: whatever navigation app the main screen runs, if it can.
val navApp by produceState<ComponentName?>(null) {
while (true) {
value = CarServices.navOrNull()?.connectedComponent
delay(2_000)
}
}
val mapApp = navApp?.let { app -> remember(app) { app.takeIf { drawsOnCluster(context, it) } } }
// The map app's turn changes a few times a minute: read it once a second. // The map app's turn changes a few times a minute: read it once a second.
val turn by produceState<ClusterTurn?>(null) { val turn by produceState<ClusterTurn?>(null) {
while (true) { while (true) {
@@ -68,9 +96,23 @@ internal fun ClusterScreen(marginX: Int, marginY: Int, clearArea: StateFlow<Inse
Modifier.fillMaxSize().padding(start = clear[0], top = clear[1], end = clear[2], bottom = clear[3]), Modifier.fillMaxSize().padding(start = clear[0], top = clear[1], end = clear[2], bottom = clear[3]),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
val t = turn
val map = mapApp?.let { clusterMap(it, densityDpi, constraints.maxWidth, constraints.maxHeight) }
if (map != null && mapShowing(map)) {
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
if (t != null) {
BoxWithConstraints(Modifier.weight(0.42f).fillMaxHeight(), contentAlignment = Alignment.CenterStart) {
// Narrower than alone: the arrow and distance alone are about 12 units wide.
TurnCard(t, minOf(maxHeight / 8.5f, maxWidth / 12))
}
Spacer(Modifier.width(12.dp))
}
MapSurface(map, densityDpi, Modifier.weight(0.58f).fillMaxHeight())
}
return@BoxWithConstraints
}
// The card is about 8.5 units tall with two lines of directions, and 14 wide: fit both ways. // The card is about 8.5 units tall with two lines of directions, and 14 wide: fit both ways.
val unit = minOf(maxHeight / 8.5f, maxWidth / 14) val unit = minOf(maxHeight / 8.5f, maxWidth / 14)
val t = turn
if (t == null) { if (t == null) {
NowPlayingLine(Modifier.align(Alignment.Center)) NowPlayingLine(Modifier.align(Alignment.Center))
return@BoxWithConstraints return@BoxWithConstraints
@@ -82,6 +124,69 @@ internal fun ClusterScreen(marginX: Int, marginY: Int, clearArea: StateFlow<Inse
} }
} }
/**
* The navigation app's cluster session, open while this is on screen. Sized for the clear area
* it shares with the card, so the app lays its map out for a cluster-shaped screen.
*/
@Composable
private fun clusterMap(app: ComponentName, densityDpi: Int, width: Int, height: Int): CarAppConnection {
val context = LocalContext.current.applicationContext
val connection = remember(app) {
CarAppConnection(context, SessionInfo(SessionInfo.DISPLAY_TYPE_CLUSTER, CLUSTER_SESSION))
}
DisposableEffect(connection) {
connection.connect(TemplateApp(app, app.packageName, emptyList()), (width * 0.58f).toInt(), height, densityDpi)
onDispose { connection.disconnect() }
}
return connection
}
/**
* Whether the map's place is kept: while the session comes up, and once running only if the app
* asked for a surface. An app whose cluster session draws nothing leaves the card on its own.
*/
@Composable
private fun mapShowing(map: CarAppConnection): Boolean {
val status by map.status.collectAsState()
return when (status.phase) {
CarAppConnection.Phase.BINDING, CarAppConnection.Phase.HANDSHAKE -> true
CarAppConnection.Phase.RUNNING -> status.surfaceRequested || status.template == null
else -> false
}
}
/** A surface lent to the cluster session; a fresh one for every size, as on the main screen. */
@Composable
private fun MapSurface(map: CarAppConnection, densityDpi: Int, modifier: Modifier) {
BoxWithConstraints(modifier) {
key(constraints.maxWidth, constraints.maxHeight) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
SurfaceView(context).apply {
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) = Unit
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) =
map.attachSurface(holder.surface, width, height, densityDpi)
override fun surfaceDestroyed(holder: SurfaceHolder) = map.detachSurface(holder.surface)
})
}
},
)
}
}
DisposableEffect(map) { onDispose { map.detachSurface() } }
}
/** Whether [component] says, in its manifest, that it has a session for instrument clusters. */
private fun drawsOnCluster(context: Context, component: ComponentName): Boolean = runCatching {
val intent = Intent(CarAppService.SERVICE_INTERFACE).addCategory(FEATURE_CLUSTER).setPackage(component.packageName)
context.packageManager.queryIntentServices(intent, 0).any { it.serviceInfo.name == component.className }
}.getOrDefault(false)
private const val FEATURE_CLUSTER = "androidx.car.app.category.FEATURE_CLUSTER"
private const val CLUSTER_SESSION = "gearslip-cluster"
/** /**
* Start, top, end and bottom padding around the card inside the visible picture. The car's own * Start, top, end and bottom padding around the card inside the visible picture. The car's own
* insets win when they leave a usable area; a wide strip, where dials sit at both ends and a bar * insets win when they leave a usable area; a wide strip, where dials sit at both ends and a bar
@@ -18,6 +18,8 @@ import android.view.Surface
import androidx.car.app.CarAppService import androidx.car.app.CarAppService
import androidx.car.app.CarContext import androidx.car.app.CarContext
import androidx.car.app.HandshakeInfo import androidx.car.app.HandshakeInfo
import androidx.car.app.SessionInfo
import androidx.car.app.SessionInfoIntentEncoder
import androidx.car.app.IAppHost import androidx.car.app.IAppHost
import androidx.car.app.IAppManager import androidx.car.app.IAppManager
import androidx.car.app.ICarApp import androidx.car.app.ICarApp
@@ -56,8 +58,12 @@ import kotlinx.coroutines.flow.asStateFlow
* *
* Every binder callback lands on a binder thread and is hopped to the main thread, because * Every binder callback lands on a binder thread and is hopped to the main thread, because
* the state here feeds Compose. * the state here feeds Compose.
*
* With a cluster [session], this is the same app's second session, for the car's instrument
* cluster: a bind of its own, which the app answers with a session of its own. It leans on the
* main session for location, so it holds none itself.
*/ */
class CarAppConnection(private val context: Context) { class CarAppConnection(private val context: Context, private val session: SessionInfo? = null) {
enum class Phase { IDLE, BINDING, HANDSHAKE, RUNNING, REJECTED, FAILED } enum class Phase { IDLE, BINDING, HANDSHAKE, RUNNING, REJECTED, FAILED }
@@ -126,6 +132,7 @@ class CarAppConnection(private val context: Context) {
update { Status(phase = Phase.BINDING, app = app.label) } update { Status(phase = Phase.BINDING, app = app.label) }
val intent = Intent(CarAppService.SERVICE_INTERFACE).setComponent(app.component) val intent = Intent(CarAppService.SERVICE_INTERFACE).setComponent(app.component)
session?.let { SessionInfoIntentEncoder.encode(it, intent) }
val connection = object : ServiceConnection { val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) { override fun onServiceConnected(name: ComponentName, service: IBinder) {
main.post { onBound(service) } main.post { onBound(service) }
@@ -179,7 +186,7 @@ class CarAppConnection(private val context: Context) {
// BIND_INCLUDE_CAPABILITIES only lends the location capability Gearslip holds at bind // BIND_INCLUDE_CAPABILITIES only lends the location capability Gearslip holds at bind
// time, so the keep-alive has to be running first - a nav app that starts its own // time, so the keep-alive has to be running first - a nav app that starts its own
// location service before then is refused, and MapQuest crashes on that. // location service before then is refused, and MapQuest crashes on that.
if (holdsLocation) { if (holdsLocation || session != null) {
bind() bind()
} else { } else {
holdsLocation = true holdsLocation = true