mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 08:12:26 +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 {
|
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
|
||||||
|
|||||||
Reference in new issue
Block a user