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 {
it.start(surface, width, height, density, name = "gearslip-cluster") {
// 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
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.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.padding
import androidx.compose.foundation.layout.size
@@ -16,18 +25,25 @@ import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
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.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
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.host.CarAppConnection
import app.seb3thehacker.gearslip.host.TemplateApp
import app.seb3thehacker.gearslip.host.clockTime
import app.seb3thehacker.gearslip.host.display
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
* 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).
*
* 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
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 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.
val turn by produceState<ClusterTurn?>(null) {
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]),
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.
val unit = minOf(maxHeight / 8.5f, maxWidth / 14)
val t = turn
if (t == null) {
NowPlayingLine(Modifier.align(Alignment.Center))
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
* 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.CarContext
import androidx.car.app.HandshakeInfo
import androidx.car.app.SessionInfo
import androidx.car.app.SessionInfoIntentEncoder
import androidx.car.app.IAppHost
import androidx.car.app.IAppManager
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
* 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 }
@@ -126,6 +132,7 @@ class CarAppConnection(private val context: Context) {
update { Status(phase = Phase.BINDING, app = app.label) }
val intent = Intent(CarAppService.SERVICE_INTERFACE).setComponent(app.component)
session?.let { SessionInfoIntentEncoder.encode(it, intent) }
val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
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
// 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.
if (holdsLocation) {
if (holdsLocation || session != null) {
bind()
} else {
holdsLocation = true