From 163e206715e61be7abad63266d9154b092619dec Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Thu, 5 Nov 2026 14:46:50 -0800 Subject: [PATCH] The cockpit shows the navigation app's own map beside the turn card, for apps with a cluster session --- .../seb3thehacker/gearslip/ClusterVideo.kt | 2 +- .../gearslip/car/ClusterScreen.kt | 109 +++++++++++++++++- .../gearslip/host/CarAppConnection.kt | 11 +- 3 files changed, 117 insertions(+), 5 deletions(-) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ClusterVideo.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ClusterVideo.kt index 5cc12ca..04645a1 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ClusterVideo.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ClusterVideo.kt @@ -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) } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ClusterScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ClusterScreen.kt index 7dfa599..0884b05 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ClusterScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ClusterScreen.kt @@ -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) { +internal fun ClusterScreen(marginX: Int, marginY: Int, densityDpi: Int, clearArea: StateFlow) { 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(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(null) { while (true) { @@ -68,9 +96,23 @@ internal fun ClusterScreen(marginX: Int, marginY: Int, clearArea: StateFlow 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 diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt index 6e01c0e..6c14bb9 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt @@ -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