mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
Place lists get a map behind them, drawn by Gearslip as Android Auto draws one
RF Sentinel and other point-of-interest apps leave this map to the host. It uses MapLibre with OpenFreeMap's tiles, which need no key or account, with a numbered pin for each place.
This commit is contained in:
1 parent
5ea62b4bd6
commit
e72e99d699
4 files changed
+191
-1
No files matched your search
@@ -198,6 +198,8 @@ dependencies {
|
|||||||
implementation(libs.androidx.media)
|
implementation(libs.androidx.media)
|
||||||
// Document-start scripts, so the ad blocker hides ad slots before a page first paints.
|
// Document-start scripts, so the ad blocker hides ad slots before a page first paints.
|
||||||
implementation(libs.androidx.webkit)
|
implementation(libs.androidx.webkit)
|
||||||
|
// The map behind a place list (PlaceListMapTemplate), which the host draws, not the app.
|
||||||
|
implementation(libs.maplibre.android)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,11 @@ fun CarAppStage(
|
|||||||
// The app's own drawing goes underneath; everything else is the host's rendering of
|
// The app's own drawing goes underneath; everything else is the host's rendering of
|
||||||
// the template it described, laid over the top.
|
// the template it described, laid over the top.
|
||||||
androidx.compose.runtime.CompositionLocalProvider(LocalChromeCover provides cover, LocalCarAppConnection provides connection) {
|
androidx.compose.runtime.CompositionLocalProvider(LocalChromeCover provides cover, LocalCarAppConnection provides connection) {
|
||||||
if (status.surfaceRequested) {
|
// A place list's map is the host's to draw; any other map is the app's own surface.
|
||||||
|
val placeList = template as? androidx.car.app.model.PlaceListMapTemplate
|
||||||
|
if (placeList != null) {
|
||||||
|
HostPlaceMap(placeList, Modifier.fillMaxSize())
|
||||||
|
} else if (status.surfaceRequested) {
|
||||||
AppSurface(connection, frame, template, embedded, Modifier.fillMaxSize())
|
AppSurface(connection, frame, template, embedded, Modifier.fillMaxSize())
|
||||||
}
|
}
|
||||||
// The docked player floats over one side of the map: the chrome keeps clear of it,
|
// The docked player floats over one side of the map: the chrome keeps clear of it,
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import android.graphics.Color as AndroidColor
|
||||||
|
import androidx.car.app.model.Place
|
||||||
|
import androidx.car.app.model.PlaceListMapTemplate
|
||||||
|
import androidx.car.app.model.Row
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
import org.maplibre.android.MapLibre
|
||||||
|
import org.maplibre.android.camera.CameraUpdateFactory
|
||||||
|
import org.maplibre.android.geometry.LatLng
|
||||||
|
import org.maplibre.android.geometry.LatLngBounds
|
||||||
|
import org.maplibre.android.maps.MapLibreMap
|
||||||
|
import org.maplibre.android.maps.MapLibreMapOptions
|
||||||
|
import org.maplibre.android.maps.MapView
|
||||||
|
import org.maplibre.android.maps.Style
|
||||||
|
import org.maplibre.android.style.expressions.Expression
|
||||||
|
import org.maplibre.android.style.layers.CircleLayer
|
||||||
|
import org.maplibre.android.style.layers.PropertyFactory
|
||||||
|
import org.maplibre.android.style.layers.SymbolLayer
|
||||||
|
import org.maplibre.android.style.sources.GeoJsonSource
|
||||||
|
import org.maplibre.geojson.Feature
|
||||||
|
import org.maplibre.geojson.FeatureCollection
|
||||||
|
import org.maplibre.geojson.Point
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The map behind a [PlaceListMapTemplate]. For this template the host draws the map, not the
|
||||||
|
* app: Android Auto puts Google's map there, with a numbered pin for each place in the list and
|
||||||
|
* the anchor marked. RF Sentinel lists its nearby places this way, and with nothing drawn the
|
||||||
|
* area stayed empty.
|
||||||
|
*
|
||||||
|
* Tiles come from OpenFreeMap: free, no key, and no account, so nothing ties a drive to anyone.
|
||||||
|
* The camera fits every pin into the part of the map the list card doesn't cover.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun HostPlaceMap(template: PlaceListMapTemplate, modifier: Modifier = Modifier) {
|
||||||
|
val dark = MaterialTheme.colorScheme.background.luminance() < 0.5f
|
||||||
|
val pin = MaterialTheme.colorScheme.primary.toArgb()
|
||||||
|
val pinText = MaterialTheme.colorScheme.onPrimary.toArgb()
|
||||||
|
val anchorColor = MaterialTheme.colorScheme.tertiary.toArgb()
|
||||||
|
val cardsAtEnd = LocalCardsAtEnd.current
|
||||||
|
val places = remember(template) { placesOf(template) }
|
||||||
|
var map by remember { mutableStateOf<MapLibreMap?>(null) }
|
||||||
|
var style by remember { mutableStateOf<Style?>(null) }
|
||||||
|
|
||||||
|
BoxWithConstraints(modifier) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
// Keep the pins out from under the list card, the way the app's own map would.
|
||||||
|
val card = with(density) { cardColumnWidth(maxWidth).roundToPx() }
|
||||||
|
val margin = with(density) { 48.dp.roundToPx() }
|
||||||
|
|
||||||
|
AndroidView(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
factory = { context ->
|
||||||
|
MapLibre.getInstance(context)
|
||||||
|
// A TextureView, so the map composites with the cards drawn over it inside the
|
||||||
|
// car's virtual display, as any other view does.
|
||||||
|
val options = MapLibreMapOptions.createFromAttributes(context).textureMode(true)
|
||||||
|
MapView(context, options).apply {
|
||||||
|
onCreate(null)
|
||||||
|
onStart()
|
||||||
|
onResume()
|
||||||
|
getMapAsync { m ->
|
||||||
|
m.uiSettings.isLogoEnabled = false
|
||||||
|
m.uiSettings.isCompassEnabled = false
|
||||||
|
m.uiSettings.isRotateGesturesEnabled = false
|
||||||
|
m.uiSettings.isTiltGesturesEnabled = false
|
||||||
|
map = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRelease = { view ->
|
||||||
|
runCatching {
|
||||||
|
view.onPause()
|
||||||
|
view.onStop()
|
||||||
|
view.onDestroy()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// The style follows the car's theme; the pins go in once it's loaded.
|
||||||
|
LaunchedEffect(map, dark) {
|
||||||
|
val m = map ?: return@LaunchedEffect
|
||||||
|
style = null
|
||||||
|
m.setStyle(Style.Builder().fromUri(if (dark) DARK_STYLE else LIGHT_STYLE)) { loaded -> style = loaded }
|
||||||
|
}
|
||||||
|
LaunchedEffect(style, places, pin, card, cardsAtEnd) {
|
||||||
|
val m = map ?: return@LaunchedEffect
|
||||||
|
val s = style ?: return@LaunchedEffect
|
||||||
|
showPins(s, places, pin, pinText, anchorColor)
|
||||||
|
val points = places.map { it.latLng }
|
||||||
|
when {
|
||||||
|
points.isEmpty() -> Unit
|
||||||
|
points.size == 1 -> m.moveCamera(CameraUpdateFactory.newLatLngZoom(points.first(), SINGLE_PLACE_ZOOM))
|
||||||
|
else -> {
|
||||||
|
val bounds = LatLngBounds.Builder().includes(points).build()
|
||||||
|
val left = if (cardsAtEnd) margin else card + margin
|
||||||
|
val right = if (cardsAtEnd) card + margin else margin
|
||||||
|
runCatching { m.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, left, margin, right, margin)) }
|
||||||
|
.onFailure { GearslipLog.w("host map: couldn't fit ${points.size} places: ${it.message}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DisposableEffect(Unit) { onDispose { map = null } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One pin: where, and the label on it (the list's number, or the app's own marker text). */
|
||||||
|
private class Pin(val latLng: LatLng, val label: String, val anchor: Boolean)
|
||||||
|
|
||||||
|
private fun placesOf(template: PlaceListMapTemplate): List<Pin> {
|
||||||
|
val rows = template.itemList?.items.orEmpty().filterIsInstance<Row>()
|
||||||
|
val pins = rows.mapIndexedNotNull { i, row ->
|
||||||
|
val place = row.metadata?.place ?: return@mapIndexedNotNull null
|
||||||
|
pinOf(place, (i + 1).toString(), anchor = false)
|
||||||
|
}
|
||||||
|
return pins + listOfNotNull(template.anchor?.let { pinOf(it, "", anchor = true) })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pinOf(place: Place, fallback: String, anchor: Boolean): Pin? {
|
||||||
|
val location = place.location
|
||||||
|
if (location.latitude == 0.0 && location.longitude == 0.0) return null
|
||||||
|
val label = place.marker?.label?.toString()?.takeIf { it.isNotBlank() } ?: fallback
|
||||||
|
return Pin(LatLng(location.latitude, location.longitude), label, anchor)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showPins(style: Style, pins: List<Pin>, pin: Int, pinText: Int, anchorColor: Int) {
|
||||||
|
val features = FeatureCollection.fromFeatures(
|
||||||
|
pins.map { p ->
|
||||||
|
Feature.fromGeometry(Point.fromLngLat(p.latLng.longitude, p.latLng.latitude)).apply {
|
||||||
|
addStringProperty("label", p.label)
|
||||||
|
addBooleanProperty("anchor", p.anchor)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val source = style.getSourceAs<GeoJsonSource>(SOURCE)
|
||||||
|
if (source != null) {
|
||||||
|
source.setGeoJson(features)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
style.addSource(GeoJsonSource(SOURCE, features))
|
||||||
|
style.addLayer(
|
||||||
|
CircleLayer(PIN_LAYER, SOURCE).withProperties(
|
||||||
|
PropertyFactory.circleRadius(Expression.switchCase(Expression.get("anchor"), Expression.literal(9f), Expression.literal(14f))),
|
||||||
|
PropertyFactory.circleColor(Expression.switchCase(Expression.get("anchor"), Expression.color(anchorColor), Expression.color(pin))),
|
||||||
|
PropertyFactory.circleStrokeColor(AndroidColor.WHITE),
|
||||||
|
PropertyFactory.circleStrokeWidth(2f),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
style.addLayer(
|
||||||
|
SymbolLayer(LABEL_LAYER, SOURCE).withProperties(
|
||||||
|
PropertyFactory.textField(Expression.get("label")),
|
||||||
|
PropertyFactory.textFont(arrayOf("Noto Sans Bold")),
|
||||||
|
PropertyFactory.textSize(13f),
|
||||||
|
PropertyFactory.textColor(pinText),
|
||||||
|
PropertyFactory.textAllowOverlap(true),
|
||||||
|
PropertyFactory.textIgnorePlacement(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val LIGHT_STYLE = "https://tiles.openfreemap.org/styles/positron"
|
||||||
|
private const val DARK_STYLE = "https://tiles.openfreemap.org/styles/dark"
|
||||||
|
private const val SOURCE = "gearslip-places"
|
||||||
|
private const val PIN_LAYER = "gearslip-place-pins"
|
||||||
|
private const val LABEL_LAYER = "gearslip-place-labels"
|
||||||
|
private const val SINGLE_PLACE_ZOOM = 14.0
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
[versions]
|
[versions]
|
||||||
|
maplibre = "13.7.1"
|
||||||
agp = "9.1.1"
|
agp = "9.1.1"
|
||||||
kotlin = "2.4.0"
|
kotlin = "2.4.0"
|
||||||
coreKtx = "1.19.0"
|
coreKtx = "1.19.0"
|
||||||
@@ -20,6 +21,7 @@ webkit = "1.16.0"
|
|||||||
androidxMedia = "1.6.0"
|
androidxMedia = "1.6.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
|
maplibre-android = { group = "org.maplibre.gl", name = "android-sdk", version.ref = "maplibre" }
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
|
|||||||
Reference in new issue
Block a user