mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
The cockpit shows the navigation app's own map beside the turn card, for apps with a cluster session
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user