diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 0f01004f..a2f1f38a 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1009,6 +1009,89 @@ The app never checks its own version: it sends `APP-` (store) or `APP-`. + +**Real cars.** A real head unit only lists apps installed from Play. A +sideloaded build is refused with `CAR.VALIDATOR: Package DENIED; failed all +other checks` even with Unknown sources on, while the Desktop Head Unit still +shows it. To test in a car, install through a Play internal testing track. + +**Play Store.** Play only accepts real turn-by-turn apps in the navigation +category, so a public release of this car map needs that decision first. A Play-safe variant +would use the template-only POI category with a host-drawn map (pins, no +coverage overlay). + ### Background Service Keeps BLE and GPS active when the app is backgrounded during auto-ping. @@ -2671,6 +2754,7 @@ debugError('[API] Failed to post batch: $error'); | `[BLE]` | Bluetooth connection and device communication | | `[CONN]` | MeshCore connection protocol operations | | `[SIRI]` | Siri App Intents bridge and snapshot publishing | +| `[CAR]` | Android Auto bridge: car session, snapshot publishing, car commands | | `[EXTERNAL]` | External command execution (shared Siri/watch lane) | | `[GPS]` | GPS/geolocation operations | | `[PING]` | Ping sending and validation | diff --git a/README.md b/README.md index f7951a3d..5e32aec8 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Built with contributions by **The Greater Ottawa Mesh Radio Enthusiasts** - **Offline Mode** — Wardrive without connectivity, upload data later - **30+ Device Models** — Automatic identification and power reporting for Ikoka, Heltec, RAK, LilyGo, Seeed, and more - **Noise Floor Graphing** — Track signal quality over time +- **Android Auto** — The Map tab on the car screen with coverage, your position, a live status card, and Start/Stop/Ping controls - **Dark/Light Theme** — System theme support --- @@ -58,6 +59,7 @@ lib/ ├── screens/ # Full-page UI screens ├── widgets/ # Reusable components └── services/ + ├── android_auto/ # Android Auto bridge and car map projection ├── bluetooth/ # BLE abstraction layer │ ├── bluetooth_service.dart # Abstract interface │ ├── mobile_bluetooth.dart # Android/iOS (flutter_blue_plus) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index e15d1410..df73dc11 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -83,4 +83,8 @@ dependencies { // 4.6.2, but declared so MeshMapperAltitudeService.kt compiles against it. // Version must match geolocator_android's transitive dependency. implementation("com.google.android.gms:play-services-location:21.2.0") + + // Android for Cars App Library, for the Android Auto screens in + // net.meshmapper.app.auto (CarMapScreen draws the map on the car surface). + implementation("androidx.car.app:app:1.4.0") } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8e0d5898..2c77917b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -32,6 +32,11 @@ + + + + + + + + + + + + + + + when (call.method) { + "attach" -> result.success(CarStatusStore.carSessionActive) + "publishSnapshot" -> { + val snapshot = CarSnapshot.fromMap(call.arguments) + if (snapshot == null) { + result.error("bad_snapshot", "Snapshot was not a map", null) + } else { + CarStatusStore.updateSnapshot(snapshot) + result.success(null) + } + } + else -> result.notImplemented() + } + } + channel = newChannel + CarStatusStore.attachChannel(newChannel) + } + + fun dispose() { + channel?.let { + it.setMethodCallHandler(null) + CarStatusStore.detachChannel(it) + } + channel = null + } +} diff --git a/android/app/src/main/kotlin/net/meshmapper/app/auto/CarMapRenderer.kt b/android/app/src/main/kotlin/net/meshmapper/app/auto/CarMapRenderer.kt new file mode 100644 index 00000000..7523b8c7 --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/auto/CarMapRenderer.kt @@ -0,0 +1,771 @@ +package net.meshmapper.app.auto + +import android.app.Presentation +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Point +import android.graphics.Rect +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.hardware.display.DisplayManager +import android.hardware.display.VirtualDisplay +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.util.TypedValue +import android.view.Gravity +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import androidx.car.app.CarContext +import androidx.car.app.SurfaceCallback +import androidx.car.app.SurfaceContainer +import org.maplibre.android.MapLibre +import org.maplibre.android.camera.CameraPosition +import org.maplibre.android.camera.CameraUpdateFactory +import org.maplibre.android.geometry.LatLng +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.FillLayer +import org.maplibre.android.style.layers.Property +import org.maplibre.android.style.layers.PropertyFactory +import org.maplibre.android.style.layers.SymbolLayer +import org.maplibre.android.style.sources.GeoJsonSource +import org.maplibre.android.style.sources.TileSet +import org.maplibre.android.style.sources.VectorSource +import org.maplibre.geojson.Feature +import org.maplibre.geojson.FeatureCollection +import org.maplibre.geojson.Point as GeoPoint +import org.maplibre.geojson.Polygon +import net.meshmapper.app.R +import kotlin.math.ln + +/// Draws MeshMapper's map onto the car display. +/// +/// Android Auto hands navigation apps a Surface and nothing else, so the map +/// is a real MapLibre [MapView] hosted in a [Presentation] on a private +/// [VirtualDisplay] backed by that Surface. It paints what the phone's Map tab +/// paints, from the snapshot Dart publishes: the user's base style, the vector +/// coverage overlay with the same palette, the session's freshly patched +/// cells on top, recently heard repeaters, and the position arrow. +/// +/// The camera follows the driver until they pan; [recenter] resumes following. +class CarMapRenderer(private val carContext: CarContext) : SurfaceCallback { + private var virtualDisplay: VirtualDisplay? = null + private var presentation: Presentation? = null + private var mapView: MapView? = null + private var batteryBadge: LinearLayout? = null + private var statusCard: LinearLayout? = null + + /// Re-renders the status card once a second so the countdown ticks + /// between snapshots, the way the phone's status bar does. + private val tickHandler = Handler(Looper.getMainLooper()) + private val tick = object : Runnable { + override fun run() { + if (statusCard == null) return + refreshStatus() + tickHandler.postDelayed(this, 1000L) + } + } + private var map: MapLibreMap? = null + private var loadedStyle: String? = null + private var styleReady = false + + private var surfaceWidth = 0 + private var surfaceHeight = 0 + private var visibleArea: Rect? = null + + private var zoom = DEFAULT_ZOOM + private var following = true + + /// Heading-up (the direction of travel points up, like Google Maps while + /// driving) or north-up. Heading-up is the default. + var headingUp = true + private set + + /// The last bearing the camera used, kept while the car is stopped and + /// GPS reports no heading, so the map does not snap back to north. + private var lastBearing = 0.0 + + /// Told when following or orientation changes, so the screen can swap + /// the centre button's icon. + var onModeChanged: (() -> Unit)? = null + + val isFollowing: Boolean + get() = following + + /// A style picked on the car, overriding the phone's until the session + /// ends. Null follows the phone's preference. + private var styleOverride: String? = null + + private var appliedCoverage: CoverageKey? = null + private var appliedPatchVersion = -1 + + private data class CoverageKey(val tiles: String, val palette: List>) + + // --- SurfaceCallback ---------------------------------------------------- + + override fun onSurfaceAvailable(surfaceContainer: SurfaceContainer) { + val surface = surfaceContainer.surface ?: return + teardown() + surfaceWidth = surfaceContainer.width + surfaceHeight = surfaceContainer.height + + MapLibre.getInstance(carContext.applicationContext) + val displayManager = carContext.getSystemService(DisplayManager::class.java) + val display = displayManager.createVirtualDisplay( + "MeshMapperCarMap", + surfaceContainer.width, + surfaceContainer.height, + surfaceContainer.dpi, + surface, + DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY, + ) + virtualDisplay = display + + val shown = Presentation(carContext, display.display) + val options = MapLibreMapOptions.createFromAttributes(shown.context) + .textureMode(true) + .compassEnabled(false) + .logoEnabled(false) + .attributionEnabled(true) + val view = MapView(shown.context, options) + val badge = createHeaderBadge(shown.context) + val root = FrameLayout(shown.context).apply { + addView(view, FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + )) + addView(badge, FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.TOP or Gravity.END, + )) + } + batteryBadge = badge + val card = createStatusCard(shown.context) + root.addView(card, FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.TOP or Gravity.START, + )) + statusCard = card + layoutBatteryBadge() + layoutStatusCard() + refreshStatus() + tickHandler.removeCallbacks(tick) + tickHandler.postDelayed(tick, 1000L) + shown.setContentView(root) + view.onCreate(null) + shown.show() + view.onStart() + view.onResume() + presentation = shown + mapView = view + + view.getMapAsync { mapLibreMap -> + if (mapView !== view) return@getMapAsync + map = mapLibreMap + mapLibreMap.uiSettings.apply { + isRotateGesturesEnabled = false + isTiltGesturesEnabled = false + } + mapLibreMap.setMinZoomPreference(MIN_ZOOM) + mapLibreMap.setMaxZoomPreference(MAX_ZOOM) + CarStatusStore.snapshot?.let { update(it) } ?: loadStyle(CarSnapshot.CarMap.DEFAULT_STYLE) + } + } + + override fun onVisibleAreaChanged(visibleArea: Rect) { + this.visibleArea = visibleArea + layoutBatteryBadge() + layoutStatusCard() + CarStatusStore.snapshot?.let { moveCamera(it.map, animate = false) } + } + + override fun onStableAreaChanged(stableArea: Rect) = Unit + + override fun onSurfaceDestroyed(surfaceContainer: SurfaceContainer) { + teardown() + } + + override fun onScroll(distanceX: Float, distanceY: Float) { + val target = map ?: return + if (following) { + following = false + onModeChanged?.invoke() + } + target.scrollBy(-distanceX, -distanceY, 0) + } + + override fun onScale(focusX: Float, focusY: Float, scaleFactor: Float) { + val target = map ?: return + val focus = if (focusX < 0 || focusY < 0) null else Point(focusX.toInt(), focusY.toInt()) + val delta = ln(scaleFactor.toDouble()) / ln(2.0) + target.moveCamera( + if (focus == null) CameraUpdateFactory.zoomBy(delta) else CameraUpdateFactory.zoomBy(delta, focus), + ) + zoom = target.cameraPosition.zoom + } + + // --- Controls ----------------------------------------------------------- + + fun zoomIn() = zoomBy(1.0) + + fun zoomOut() = zoomBy(-1.0) + + /// The centre button, Google Maps style: re-centres when the driver has + /// panned away, otherwise switches between heading-up and north-up. + fun centreButtonTapped() { + if (following) { + headingUp = !headingUp + } else { + following = true + zoom = DEFAULT_ZOOM + } + onModeChanged?.invoke() + CarStatusStore.snapshot?.let { moveCamera(it.map, animate = true) } + } + + /// Cycles Liberty → Dark → Bright → Satellite on the car only, the phone's + /// own cycle order, starting from whatever the phone is using. + fun cycleStyle() { + val current = styleOverride ?: CarStatusStore.snapshot?.map?.style ?: CarSnapshot.CarMap.DEFAULT_STYLE + val index = STYLE_CYCLE.indexOfFirst { it == current } + styleOverride = STYLE_CYCLE[(index + 1).mod(STYLE_CYCLE.size)] + CarStatusStore.snapshot?.let { update(it) } + } + + private fun zoomBy(delta: Double) { + val target = map ?: return + zoom = (target.cameraPosition.zoom + delta).coerceIn(MIN_ZOOM, MAX_ZOOM) + target.easeCamera(CameraUpdateFactory.zoomTo(zoom), 300) + } + + // --- Snapshot ----------------------------------------------------------- + + fun update(snapshot: CarSnapshot) { + updateBatteryBadge(snapshot) + refreshStatus() + val target = map ?: return + val wanted = styleOverride ?: snapshot.map.style + if (wanted != loadedStyle) { + loadStyle(wanted) + return + } + val style = target.style ?: return + if (!styleReady || !style.isFullyLoaded) return + + applyCoverage(style, snapshot.map) + applyPatch(style, snapshot.map) + applyRepeaters(style, snapshot) + applyPuck(style, snapshot.map) + moveCamera(snapshot.map, animate = true) + } + + private fun loadStyle(style: String) { + val target = map ?: return + loadedStyle = style + styleReady = false + appliedCoverage = null + appliedPatchVersion = -1 + val builder = if (style.trimStart().startsWith("{")) { + Style.Builder().fromJson(style) + } else { + Style.Builder().fromUri(style) + } + target.setStyle(builder) { loaded -> + if (loadedStyle != style) return@setStyle + loaded.addImage(PUCK_IMAGE, puckBitmap()) + styleReady = true + CarStatusStore.snapshot?.let { update(it) } + } + } + + private fun applyCoverage(style: Style, carMap: CarSnapshot.CarMap) { + val tiles = carMap.coverageTiles + val key = tiles?.let { CoverageKey(it, carMap.palette) } + if (key != appliedCoverage) { + style.removeLayer(COVERAGE_LAYER) + style.removeSource(COVERAGE_SOURCE) + appliedCoverage = null + if (key != null) { + val tileSet = TileSet("2.1.0", key.tiles).apply { + setMinZoom(2f) + setMaxZoom(14f) + } + style.addSource(VectorSource(COVERAGE_SOURCE, tileSet)) + val layer = FillLayer(COVERAGE_LAYER, COVERAGE_SOURCE).withSourceLayer("coverage") + layer.setProperties( + PropertyFactory.fillColor(paletteExpression(carMap.palette, border = false)), + PropertyFactory.fillOutlineColor(paletteExpression(carMap.palette, border = true)), + ) + addBelowOverlays(style, layer) + appliedCoverage = key + } + } + style.getLayer(COVERAGE_LAYER)?.setProperties(PropertyFactory.fillOpacity(carMap.coverageOpacity)) + } + + /// The session's own freshly scored cells, painted over the base overlay + /// with the same styling, as the phone's patch layer does. + private fun applyPatch(style: Style, carMap: CarSnapshot.CarMap) { + val features = carMap.patch.map { cell -> + val (lat0, lon0, latStep, lonStep, st) = cell + val ring = listOf( + GeoPoint.fromLngLat(lon0, lat0), + GeoPoint.fromLngLat(lon0 + lonStep, lat0), + GeoPoint.fromLngLat(lon0 + lonStep, lat0 + latStep), + GeoPoint.fromLngLat(lon0, lat0 + latStep), + GeoPoint.fromLngLat(lon0, lat0), + ) + Feature.fromGeometry(Polygon.fromLngLats(listOf(ring))).apply { + addNumberProperty("st", st.toInt()) + } + } + val collection = FeatureCollection.fromFeatures(features) + val source = style.getSourceAs(PATCH_SOURCE) + if (source == null) { + style.addSource(GeoJsonSource(PATCH_SOURCE, collection)) + val layer = FillLayer(PATCH_LAYER, PATCH_SOURCE) + addBelowOverlays(style, layer) + } else if (carMap.patchVersion != appliedPatchVersion) { + source.setGeoJson(collection) + } + appliedPatchVersion = carMap.patchVersion + style.getLayer(PATCH_LAYER)?.setProperties( + PropertyFactory.fillColor(paletteExpression(carMap.palette, border = false)), + PropertyFactory.fillOutlineColor(paletteExpression(carMap.palette, border = true)), + PropertyFactory.fillOpacity(if (carMap.coverageTiles == null) 0f else carMap.coverageOpacity), + ) + } + + private fun applyRepeaters(style: Style, snapshot: CarSnapshot) { + val features = snapshot.recentHeard + .distinctBy { it.key } + .filter { it.hasLocation } + .map { node -> + Feature.fromGeometry(GeoPoint.fromLngLat(node.longitude!!, node.latitude!!)).apply { + addStringProperty("label", node.label) + addBooleanProperty("direct", node.direct) + } + } + val collection = FeatureCollection.fromFeatures(features) + val source = style.getSourceAs(REPEATER_SOURCE) + if (source != null) { + source.setGeoJson(collection) + return + } + style.addSource(GeoJsonSource(REPEATER_SOURCE, collection)) + style.addLayer( + CircleLayer(REPEATER_DOT_LAYER, REPEATER_SOURCE).withProperties( + PropertyFactory.circleRadius(7f), + PropertyFactory.circleColor( + Expression.switchCase( + Expression.get("direct"), + Expression.color(Color.parseColor("#1e7e34")), + Expression.color(Color.parseColor("#2196F3")), + ), + ), + PropertyFactory.circleStrokeColor(Color.WHITE), + PropertyFactory.circleStrokeWidth(2f), + ), + ) + style.addLayer( + SymbolLayer(REPEATER_LABEL_LAYER, REPEATER_SOURCE).withProperties( + PropertyFactory.textField(Expression.get("label")), + PropertyFactory.textFont(arrayOf("Noto Sans Regular")), + PropertyFactory.textSize(13f), + PropertyFactory.textOffset(arrayOf(0f, 1.3f)), + PropertyFactory.textColor(Color.WHITE), + PropertyFactory.textHaloColor(Color.BLACK), + PropertyFactory.textHaloWidth(1.5f), + PropertyFactory.textAllowOverlap(false), + ), + ) + } + + private fun applyPuck(style: Style, carMap: CarSnapshot.CarMap) { + val lat = carMap.latitude + val lon = carMap.longitude + val features = if (lat == null || lon == null) { + emptyList() + } else { + listOf( + Feature.fromGeometry(GeoPoint.fromLngLat(lon, lat)).apply { + addNumberProperty("heading", carMap.heading ?: 0.0) + }, + ) + } + val collection = FeatureCollection.fromFeatures(features) + val source = style.getSourceAs(PUCK_SOURCE) + if (source != null) { + source.setGeoJson(collection) + return + } + style.addSource(GeoJsonSource(PUCK_SOURCE, collection)) + style.addLayer( + SymbolLayer(PUCK_LAYER, PUCK_SOURCE).withProperties( + PropertyFactory.iconImage(PUCK_IMAGE), + PropertyFactory.iconRotate(Expression.get("heading")), + PropertyFactory.iconRotationAlignment(Property.ICON_ROTATION_ALIGNMENT_MAP), + PropertyFactory.iconAllowOverlap(true), + PropertyFactory.iconIgnorePlacement(true), + ), + ) + } + + /// Coverage and patch sit beneath the repeater and puck layers, which are + /// added after them, but above every layer of the base style. + private fun addBelowOverlays(style: Style, layer: FillLayer) { + val anchor = listOf(REPEATER_DOT_LAYER, PUCK_LAYER).firstOrNull { style.getLayer(it) != null } + if (anchor != null) style.addLayerBelow(layer, anchor) else style.addLayer(layer) + // The patch must stay above the base overlay whichever was added first. + if (layer.id == COVERAGE_LAYER && style.getLayer(PATCH_LAYER) != null) { + style.removeLayer(COVERAGE_LAYER) + style.addLayerBelow(layer, PATCH_LAYER) + } + } + + private fun moveCamera(carMap: CarSnapshot.CarMap, animate: Boolean) { + val target = map ?: return + if (!following) return + val lat = carMap.latitude ?: return + val lon = carMap.longitude ?: return + val area = visibleArea + val heading = carMap.heading + if (heading != null) lastBearing = heading + val bearing = if (headingUp) lastBearing else 0.0 + val builder = CameraPosition.Builder() + .target(LatLng(lat, lon)) + .zoom(zoom) + .bearing(bearing) + if (area != null && surfaceWidth > 0 && surfaceHeight > 0) { + // Heading-up puts the car in the lower part of the view, as a + // navigation map does, so more of the road ahead is visible. + val ahead = if (headingUp) area.height() * HEADING_UP_LOOK_AHEAD else 0.0 + builder.padding( + area.left.toDouble(), + area.top.toDouble() + ahead, + (surfaceWidth - area.right).toDouble().coerceAtLeast(0.0), + (surfaceHeight - area.bottom).toDouble().coerceAtLeast(0.0), + ) + } + val update = CameraUpdateFactory.newCameraPosition(builder.build()) + if (animate) target.easeCamera(update, 900) else target.moveCamera(update) + } + + /// The phone header's two readings, noise floor and companion battery, + /// in one box at the top right of the map, under the host's buttons. + /// Drawn on our surface rather than by the host, so it stays inside the + /// visible area the host reports. + private fun createHeaderBadge(context: android.content.Context): LinearLayout = + LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + val padH = dp(context, 12f) + val padV = dp(context, 8f) + setPadding(padH, padV, padH, padV) + background = GradientDrawable().apply { + cornerRadius = dp(context, 12f).toFloat() + setColor(Color.argb(230, 0x1E, 0x29, 0x3B)) + } + // [speaker][-115dBm] [battery][97%], each icon centred on its text. + addView(headerIcon(context)) + addView(headerText(context)) + addView(headerIcon(context).apply { + (layoutParams as LinearLayout.LayoutParams).marginStart = dp(context, 14f) + }) + addView(headerText(context)) + visibility = android.view.View.GONE + } + + private fun headerIcon(context: android.content.Context): android.widget.ImageView = + android.widget.ImageView(context).apply { + val size = dp(context, HEADER_ICON_DP) + layoutParams = LinearLayout.LayoutParams(size, size).apply { + gravity = Gravity.CENTER_VERTICAL + marginEnd = dp(context, 4f) + } + scaleType = android.widget.ImageView.ScaleType.FIT_CENTER + } + + private fun headerText(context: android.content.Context): TextView = + TextView(context).apply { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ).apply { gravity = Gravity.CENTER_VERTICAL } + gravity = Gravity.CENTER_VERTICAL + // Same size as the status card's top line. + setTextSize(TypedValue.COMPLEX_UNIT_SP, HEADER_TEXT_SP) + typeface = Typeface.DEFAULT_BOLD + includeFontPadding = false + } + + private fun layoutBatteryBadge() { + val badge = batteryBadge ?: return + val params = badge.layoutParams as? FrameLayout.LayoutParams ?: return + val margin = dp(badge.context, 10f) + val area = visibleArea + params.gravity = Gravity.TOP or Gravity.END + params.topMargin = margin + (area?.top ?: 0).coerceAtLeast(0) + params.rightMargin = margin + + if (area != null && surfaceWidth > 0) (surfaceWidth - area.right).coerceAtLeast(0) else 0 + badge.layoutParams = params + } + + private fun updateBatteryBadge(snapshot: CarSnapshot) { + val badge = batteryBadge ?: return + if (!snapshot.isConnected) { + badge.visibility = android.view.View.GONE + return + } + val context = badge.context + val iconSize = dp(context, HEADER_ICON_DP) + + val noiseColor = snapshot.noiseFloorColor ?: Color.GRAY + (badge.getChildAt(0) as android.widget.ImageView).apply { + setImageResource(R.drawable.ic_car_volume_up) + setColorFilter(noiseColor) + } + (badge.getChildAt(1) as TextView).apply { + text = snapshot.noiseFloor?.let { "${it}dBm" } ?: "--dBm" + setTextColor(noiseColor) + } + + val percent = snapshot.batteryPercent + val batteryColor = when { + percent == null -> Color.GRAY + percent >= 50 -> Color.parseColor("#4CAF50") + percent >= 20 -> Color.parseColor("#FF9800") + else -> Color.parseColor("#F44336") + } + (badge.getChildAt(2) as android.widget.ImageView) + .setImageBitmap(batteryBitmap(iconSize, percent, batteryColor)) + (badge.getChildAt(3) as TextView).apply { + text = percent?.let { "$it%" } ?: "--%" + setTextColor(batteryColor) + } + + badge.visibility = android.view.View.VISIBLE + } + + /// A vertical battery like the phone header's, filled to the level. + private fun batteryBitmap(size: Int, percent: Int?, color: Int): Bitmap { + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val unit = size / 24f + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = color } + // Terminal nub, then the body outline. + canvas.drawRect(9.5f * unit, 2f * unit, 14.5f * unit, 4f * unit, paint) + paint.style = Paint.Style.STROKE + paint.strokeWidth = 1.6f * unit + canvas.drawRoundRect(7f * unit, 4f * unit, 17f * unit, 22f * unit, 1.5f * unit, 1.5f * unit, paint) + // Fill from the bottom, in proportion to the charge. + val level = ((percent ?: 0).coerceIn(0, 100)) / 100f + if (level > 0f) { + paint.style = Paint.Style.FILL + val top = 21f - 16f * level + canvas.drawRect(8.6f * unit, top * unit, 15.4f * unit, 20.4f * unit, paint) + } + return bitmap + } + + /// The phone's status bar and counter chips, drawn top left: the phase + /// with its countdown, then the counters. + private fun createStatusCard(context: android.content.Context): LinearLayout = + LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + val padH = dp(context, 12f) + val padV = dp(context, 8f) + setPadding(padH, padV, padH, padV) + background = GradientDrawable().apply { + cornerRadius = dp(context, 12f).toFloat() + setColor(Color.argb(230, 0x1E, 0x29, 0x3B)) + } + addView(cardLine(context, 17f * CARD_SCALE, bold = true, color = Color.WHITE)) + addView(cardLine(context, 15f * CARD_SCALE, bold = false, color = Color.parseColor("#CBD5E1"))) + } + + private fun cardLine(context: android.content.Context, sizeSp: Float, bold: Boolean, color: Int): TextView = + TextView(context).apply { + // Each line is only as wide as its own text, so the card shrinks + // back when a long line goes away instead of keeping its width. + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ) + setTextSize(TypedValue.COMPLEX_UNIT_SP, sizeSp) + if (bold) typeface = Typeface.DEFAULT_BOLD + setTextColor(color) + maxLines = 1 + ellipsize = android.text.TextUtils.TruncateAt.END + // Box hugs the text: no extra space above and below each line. + includeFontPadding = false + setPadding(0, dp(context, 1f), 0, dp(context, 1f)) + } + + private fun layoutStatusCard() { + val card = statusCard ?: return + val params = card.layoutParams as? FrameLayout.LayoutParams ?: return + val margin = dp(card.context, 10f) + val area = visibleArea + params.leftMargin = margin + (area?.left ?: 0).coerceAtLeast(0) + params.topMargin = margin + (area?.top ?: 0).coerceAtLeast(0) + // Never run under the header box on the right. + val width = if (area != null) area.width() else surfaceWidth + if (width > 0) card.maxWidthFor((width * 0.6f).toInt()) + card.layoutParams = params + } + + private fun LinearLayout.maxWidthFor(px: Int) { + for (i in 0 until childCount) (getChildAt(i) as? TextView)?.maxWidth = px + } + + /// Redraws the status card from the latest snapshot. + fun refreshStatus() { + val card = statusCard ?: return + val lines = CarStatusText.lines(CarStatusStore.snapshot, CarStatusStore.isAppAttached) + val values = listOf(lines.status, lines.chips) + values.forEachIndexed { index, value -> + val view = card.getChildAt(index) as TextView + view.text = value ?: "" + view.visibility = if (value.isNullOrBlank()) android.view.View.GONE else android.view.View.VISIBLE + } + card.requestLayout() + } + + private fun dp(context: android.content.Context, value: Float): Int = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value, context.resources.displayMetrics).toInt() + + private fun teardown() { + tickHandler.removeCallbacks(tick) + statusCard = null + batteryBadge = null + map = null + styleReady = false + loadedStyle = null + appliedCoverage = null + appliedPatchVersion = -1 + mapView?.let { + try { + it.onPause() + it.onStop() + it.onDestroy() + } catch (error: RuntimeException) { + Log.w(TAG, "MapView teardown failed", error) + } + } + mapView = null + presentation?.dismiss() + presentation = null + virtualDisplay?.release() + virtualDisplay = null + } + + fun dispose() = teardown() + + companion object { + private const val TAG = "MeshMapperCarMap" + + /// Type size of the header box (noise floor and battery). + private const val HEADER_TEXT_SP = 17f + + /// The status card's type, scaled up from the header's sizes. + private const val CARD_SCALE = 1.4f + private const val HEADER_ICON_DP = 20f + + /// Share of the visible height added above the car in heading-up mode. + private const val HEADING_UP_LOOK_AHEAD = 0.35 + + /// The phone map's "closer zoom for driving". + const val DEFAULT_ZOOM = 15.0 + private const val MIN_ZOOM = 3.0 + private const val MAX_ZOOM = 16.999 + + private const val COVERAGE_SOURCE = "meshmapper-car-coverage" + private const val COVERAGE_LAYER = "meshmapper-car-coverage-layer" + private const val PATCH_SOURCE = "meshmapper-car-patch" + private const val PATCH_LAYER = "meshmapper-car-patch-layer" + private const val REPEATER_SOURCE = "meshmapper-car-repeaters" + private const val REPEATER_DOT_LAYER = "meshmapper-car-repeater-dots" + private const val REPEATER_LABEL_LAYER = "meshmapper-car-repeater-labels" + private const val PUCK_SOURCE = "meshmapper-car-puck" + private const val PUCK_LAYER = "meshmapper-car-puck-layer" + private const val PUCK_IMAGE = "meshmapper-car-puck-arrow" + + private val STYLE_CYCLE = listOf( + "https://tiles.openfreemap.org/styles/liberty", + "https://tiles.openfreemap.org/styles/dark", + "https://tiles.openfreemap.org/styles/bright", + SATELLITE_STYLE_JSON, + ) + + /// Same inline style as lib/utils/map_style_urls.dart, kept in step + /// so the car's own style cycle can reach satellite. + private const val SATELLITE_STYLE_JSON = + "{\"version\":8,\"glyphs\":\"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf\"," + + "\"sources\":{\"satellite\":{\"type\":\"raster\",\"tiles\":[\"https://server.arcgisonline.com/" + + "ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}\"],\"tileSize\":256," + + "\"maxzoom\":17}},\"layers\":[{\"id\":\"satellite-layer\",\"type\":\"raster\",\"source\":\"satellite\"}]}" + + /// `match(get("st"), 1, c1, … 5, c5, c6)`: st 6 doubles as the default + /// so unknown codes paint red, as CoverageTilePalette does in Dart. + private fun paletteExpression(palette: List>, border: Boolean): Expression { + val colors = (1..6).map { st -> + val pair = palette.getOrNull(st - 1) ?: FALLBACK_PALETTE[st - 1] + parse(if (border) pair.second else pair.first) + } + return Expression.match( + Expression.toNumber(Expression.get("st")), + Expression.color(colors[5]), + *(0 until 5).map { Expression.stop(it + 1, Expression.color(colors[it])) }.toTypedArray(), + ) + } + + private val FALLBACK_PALETTE = listOf( + "#1e7e34" to "#14522d", + "#17a2b8" to "#117a8b", + "#fd7e14" to "#d96b0c", + "#6f42c1" to "#59359a", + "#6c757d" to "#545b62", + "#bd2130" to "#8b101b", + ) + + private fun parse(hex: String): Int = + try { + Color.parseColor(hex) + } catch (_: IllegalArgumentException) { + Color.GRAY + } + + /// The phone's default arrow puck: a blue arrow inside a white outline. + private fun puckBitmap(): Bitmap { + val size = 48 + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + canvas.translate(size / 2f, size / 2f) + canvas.scale(size / 24f, size / 24f) + val outline = Path().apply { + moveTo(0f, -11f); lineTo(8f, 9f); lineTo(0f, 4f); lineTo(-8f, 9f); close() + } + val arrow = Path().apply { + moveTo(0f, -9f); lineTo(6f, 7f); lineTo(0f, 3f); lineTo(-6f, 7f); close() + } + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + paint.color = Color.WHITE + canvas.drawPath(outline, paint) + paint.color = Color.parseColor("#2196F3") + canvas.drawPath(arrow, paint) + return bitmap + } + } +} diff --git a/android/app/src/main/kotlin/net/meshmapper/app/auto/CarMapScreen.kt b/android/app/src/main/kotlin/net/meshmapper/app/auto/CarMapScreen.kt new file mode 100644 index 00000000..af8ef00e --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/auto/CarMapScreen.kt @@ -0,0 +1,134 @@ +package net.meshmapper.app.auto + +import androidx.car.app.AppManager +import androidx.car.app.CarContext +import androidx.car.app.model.Action +import androidx.car.app.model.ActionStrip +import androidx.car.app.model.CarColor +import androidx.car.app.model.CarIcon +import androidx.car.app.model.Template +import androidx.car.app.navigation.model.NavigationTemplate +import androidx.core.graphics.drawable.IconCompat +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import net.meshmapper.app.R + +/// The car's home screen: the phone's Map tab, laid out for a head unit. +/// +/// Android Auto gives a navigation app a full-screen map plus fixed slots, so +/// the phone's layout maps onto them: +/// - the map itself (base style, coverage squares, live session cells, +/// heard repeaters, position arrow) is drawn by [CarMapRenderer]; +/// - the top counter chips (TX, RX, discovery, trace, upload queue) and the +/// status bar become a status card drawn on the map, top left; +/// - the right-hand map buttons become the map action strip (pan, zoom, +/// recentre); +/// - the controls panel becomes the action strip (Start/Stop, Ping, map +/// style, and More for the dashboard with the other modes and heard list). +class CarMapScreen(carContext: CarContext) : CarStatusScreen(carContext) { + private val renderer = CarMapRenderer(carContext) + + private val mapListener = CarStatusStore.Listener { + val snapshot = CarStatusStore.snapshot + if (snapshot != null) renderer.update(snapshot) else renderer.refreshStatus() + } + + init { + renderer.onModeChanged = { invalidate() } + carContext.getCarService(AppManager::class.java).setSurfaceCallback(renderer) + lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onCreate(owner: LifecycleOwner) { + // The map follows every snapshot even while another screen is + // pushed on top, so it is current when the driver comes back. + CarStatusStore.addListener(mapListener) + } + + override fun onDestroy(owner: LifecycleOwner) { + CarStatusStore.removeListener(mapListener) + renderer.dispose() + } + }) + } + + /// The status card (phase and countdown, counters, detail) is drawn on + /// the map by [CarMapRenderer] rather than as the host's navigation info, + /// so its type size and layout match the phone instead of the host's. + override fun onGetTemplate(): Template { + val snapshot = CarStatusStore.snapshot + return NavigationTemplate.Builder() + .setActionStrip(actionStrip(snapshot)) + .setMapActionStrip(mapActionStrip()) + .setBackgroundColor(CarColor.createCustom(0xFF1E293B.toInt(), 0xFF1E293B.toInt())) + .build() + } + + private fun actionStrip(snapshot: CarSnapshot?): ActionStrip { + val strip = ActionStrip.Builder() + if (snapshot != null && !snapshot.sessionStarting) { + when { + snapshot.sessionActive && snapshot.canStop -> strip.addAction( + Action.Builder() + .setTitle("Stop") + .setOnClickListener { + runCommand( + kind = "stopSession", + sessionId = snapshot.sessionId, + fallbackSuccess = "Stopped", + ) + } + .build(), + ) + !snapshot.sessionActive && snapshot.canStart && + "passive" in snapshot.availableStartModes -> strip.addAction( + Action.Builder() + .setTitle("Start") + .setOnClickListener { + runCommand( + kind = "startSession", + mode = "passive", + fallbackSuccess = "Started", + ) + } + .build(), + ) + } + if (snapshot.canManualPing) { + strip.addAction( + Action.Builder() + .setTitle("Ping") + .setOnClickListener { + runCommand(kind = "manualPing", fallbackSuccess = "Ping sent") + } + .build(), + ) + } + } + strip.addAction(iconAction(R.drawable.ic_car_layers) { renderer.cycleStyle() }) + strip.addAction( + iconAction(R.drawable.ic_car_more) { screenManager.push(DashboardScreen(carContext)) }, + ) + return strip.build() + } + + private fun mapActionStrip(): ActionStrip = + ActionStrip.Builder() + .addAction(Action.PAN) + .addAction(iconAction(R.drawable.ic_car_zoom_in) { renderer.zoomIn() }) + .addAction(iconAction(R.drawable.ic_car_zoom_out) { renderer.zoomOut() }) + .addAction(iconAction(centreIcon()) { renderer.centreButtonTapped() }) + .build() + + /// Re-centre after panning; otherwise the mode a tap switches to + /// shows as the current one, as Google Maps' compass button does. + private fun centreIcon(): Int = when { + !renderer.isFollowing -> R.drawable.ic_car_my_location + renderer.headingUp -> R.drawable.ic_car_heading_up + else -> R.drawable.ic_car_north_up + } + + private fun iconAction(icon: Int, onClick: () -> Unit): Action = + Action.Builder() + .setIcon(CarIcon.Builder(IconCompat.createWithResource(carContext, icon)).build()) + .setOnClickListener { onClick() } + .build() +} diff --git a/android/app/src/main/kotlin/net/meshmapper/app/auto/CarSnapshot.kt b/android/app/src/main/kotlin/net/meshmapper/app/auto/CarSnapshot.kt new file mode 100644 index 00000000..cd94a78b --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/auto/CarSnapshot.kt @@ -0,0 +1,197 @@ +package net.meshmapper.app.auto + +/// Typed view of the snapshot Dart publishes on `meshmapper/android_auto`. +/// +/// The map is the Siri projection built by AppStateProvider +/// (`_buildAndroidAutoSnapshotMap`), so the keys here must track +/// siri_snapshot_models.dart. Every read is defensive: a missing or mistyped +/// field renders as unknown instead of crashing the car session. +data class CarSnapshot( + val isConnected: Boolean, + val deviceName: String?, + val batteryPercent: Int?, + val gpsStatus: String?, + /// The phone header's noise floor in dBm, and its colour (ARGB). + val noiseFloor: Int?, + val noiseFloorColor: Int?, + val sessionId: String?, + val sessionActive: Boolean, + val sessionStarting: Boolean, + val mode: String, + val phaseTitle: String?, + val phaseDetail: String?, + /// When the current phase's countdown ends (wall clock), or null. + val phaseEndsAtMs: Long?, + val zoneCode: String?, + val txCount: Int, + val rxCount: Int, + val discoveryCount: Int, + val traceCount: Int, + val queueSize: Int, + val uniqueRepeatersHeard: Int, + val availableStartModes: List, + val canStart: Boolean, + val startBlockedReason: String?, + val canStop: Boolean, + val canManualPing: Boolean, + val manualPingBlockedReason: String?, + val recentHeard: List, + val map: CarMap, +) { + /// Everything the car's own MapLibre map draws, resolved in Dart by + /// android_auto_map_projection.dart so it matches the phone's map. + data class CarMap( + val latitude: Double?, + val longitude: Double?, + val heading: Double?, + /// Style URL, or inline style JSON when it starts with `{`. + val style: String, + /// Vector coverage tile URL template, or null for no overlay. + val coverageTiles: String?, + val coverageOpacity: Float, + /// `[fill, border]` hex for coverage status 1..6. + val palette: List>, + val patchVersion: Int, + /// `[lat0, lon0, latStep, lonStep, st]` per freshly patched cell. + val patch: List, + ) { + val hasPosition: Boolean + get() = latitude != null && longitude != null + + companion object { + const val DEFAULT_STYLE = "https://tiles.openfreemap.org/styles/liberty" + + fun fromMap(raw: Any?): CarMap { + val map = raw as? Map<*, *> ?: emptyMap() + val palette = (map["palette"] as? List<*>).orEmpty().mapNotNull { entry -> + val pair = entry as? List<*> ?: return@mapNotNull null + val fill = pair.getOrNull(0) as? String ?: return@mapNotNull null + val border = pair.getOrNull(1) as? String ?: fill + fill to border + } + val patch = (map["patch"] as? List<*>).orEmpty().mapNotNull { entry -> + val cell = entry as? List<*> ?: return@mapNotNull null + if (cell.size < 5) return@mapNotNull null + val values = cell.take(5).map { (it as? Number)?.toDouble() } + if (values.any { it == null || !it.isFinite() }) return@mapNotNull null + values.map { it!! }.toDoubleArray() + } + return CarMap( + latitude = (map["lat"] as? Number)?.toDouble(), + longitude = (map["lon"] as? Number)?.toDouble(), + heading = (map["heading"] as? Number)?.toDouble(), + style = (map["style"] as? String)?.takeIf { it.isNotBlank() } ?: DEFAULT_STYLE, + coverageTiles = (map["coverageTiles"] as? String)?.takeIf { it.isNotBlank() }, + coverageOpacity = ((map["coverageOpacity"] as? Number)?.toFloat() ?: 0.7f) + .coerceIn(0f, 1f), + palette = palette, + patchVersion = (map["patchVersion"] as? Number)?.toInt() ?: 0, + patch = patch, + ) + } + } + } + + data class HeardNode( + val entityId: String?, + val name: String?, + val displayHexId: String?, + val observedAtMs: Long, + val kind: String?, + val direct: Boolean, + val hopCount: Int?, + val snr: Double?, + val rssi: Int?, + val distanceM: Double?, + val latitude: Double?, + val longitude: Double?, + ) { + /// Stable identity for collapsing repeat observations of one repeater. + val key: String + get() = entityId ?: displayHexId ?: label + + val hasLocation: Boolean + get() = latitude != null && longitude != null && + latitude in -90.0..90.0 && longitude in -180.0..180.0 && + !(latitude == 0.0 && longitude == 0.0) + + val label: String + get() = name?.takeIf { it.isNotBlank() } ?: displayHexId ?: "Unknown repeater" + } + + companion object { + fun fromMap(raw: Any?): CarSnapshot? { + val map = raw as? Map<*, *> ?: return null + val connection = map["connection"] as? Map<*, *> ?: emptyMap() + val session = map["session"] as? Map<*, *> ?: emptyMap() + val controls = map["controls"] as? Map<*, *> ?: emptyMap() + val header = map["header"] as? Map<*, *> ?: emptyMap() + val heard = (map["recentHeard"] as? List<*>).orEmpty().mapNotNull { item -> + val node = item as? Map<*, *> ?: return@mapNotNull null + HeardNode( + entityId = node.string("entityId"), + name = node.string("name"), + displayHexId = node.string("displayHexId"), + observedAtMs = node.long("observedAtMs") ?: 0L, + kind = node.string("kind"), + direct = node["direct"] == true, + hopCount = node.int("hopCount"), + snr = node.double("snr"), + rssi = node.int("rssi"), + distanceM = node.double("distanceM"), + latitude = node.double("repeaterLat"), + longitude = node.double("repeaterLon"), + ) + } + return CarSnapshot( + isConnected = connection["isConnected"] == true, + deviceName = connection.string("deviceName"), + batteryPercent = connection.int("batteryPercent"), + gpsStatus = connection.string("gpsStatus"), + noiseFloor = header.int("noiseFloor"), + noiseFloorColor = (header["noiseFloorColor"] as? Number)?.toLong()?.toInt(), + sessionId = session.string("id"), + sessionActive = session["active"] == true, + sessionStarting = session["starting"] == true, + mode = session.string("mode") ?: "idle", + phaseTitle = session.string("phaseTitle"), + phaseDetail = session.string("phaseDetail"), + phaseEndsAtMs = session.long("phaseEndsAtMs"), + zoneCode = session.string("zoneCode"), + txCount = session.int("txCount") ?: 0, + rxCount = session.int("rxCount") ?: 0, + discoveryCount = session.int("discoveryCount") ?: 0, + traceCount = session.int("traceCount") ?: 0, + queueSize = session.int("queueSize") ?: 0, + uniqueRepeatersHeard = session.int("uniqueRepeatersHeard") ?: 0, + availableStartModes = (controls["availableStartModes"] as? List<*>) + .orEmpty().filterIsInstance(), + canStart = controls["canStart"] == true, + startBlockedReason = controls.string("startBlockedReason"), + canStop = controls["canStop"] == true, + canManualPing = controls["canManualPing"] == true, + manualPingBlockedReason = controls.string("manualPingBlockedReason"), + recentHeard = heard, + map = CarMap.fromMap(map["map"]), + ) + } + + /// Display name for a wire mode, matching AutoMode.displayName in Dart. + fun modeLabel(mode: String): String = when (mode) { + "active" -> "Active" + "passive" -> "Passive" + "hybrid" -> "Hybrid" + "targeted" -> "Trace" + else -> mode.replaceFirstChar { it.uppercase() } + } + + private fun Map<*, *>.string(key: String): String? = + (this[key] as? String)?.takeIf { it.isNotEmpty() } + + private fun Map<*, *>.int(key: String): Int? = (this[key] as? Number)?.toInt() + + private fun Map<*, *>.long(key: String): Long? = (this[key] as? Number)?.toLong() + + private fun Map<*, *>.double(key: String): Double? = (this[key] as? Number)?.toDouble() + } +} diff --git a/android/app/src/main/kotlin/net/meshmapper/app/auto/CarStatusScreen.kt b/android/app/src/main/kotlin/net/meshmapper/app/auto/CarStatusScreen.kt new file mode 100644 index 00000000..60f6078c --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/auto/CarStatusScreen.kt @@ -0,0 +1,75 @@ +package net.meshmapper.app.auto + +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import androidx.car.app.CarContext +import androidx.car.app.CarToast +import androidx.car.app.Screen +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner + +/// Base for screens that redraw from [CarStatusStore]. +/// +/// Listens only while visible, and coalesces store updates so the template is +/// rebuilt at most once per [MIN_REFRESH_INTERVAL_MS]. A busy session notifies +/// several times a second; hosts throttle template updates anyway, and asking +/// for more than they accept just drops frames. +abstract class CarStatusScreen(carContext: CarContext) : Screen(carContext) { + private val handler = Handler(Looper.getMainLooper()) + private var lastInvalidateAt = 0L + private var refreshPending = false + + private val refresh = Runnable { + refreshPending = false + lastInvalidateAt = SystemClock.uptimeMillis() + invalidate() + } + + private val listener = CarStatusStore.Listener { scheduleRefresh() } + + init { + lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + CarStatusStore.addListener(listener) + // State may have moved while another screen was on top. + scheduleRefresh() + } + + override fun onStop(owner: LifecycleOwner) { + CarStatusStore.removeListener(listener) + handler.removeCallbacks(refresh) + refreshPending = false + } + }) + } + + private fun scheduleRefresh() { + if (refreshPending) return + refreshPending = true + val wait = MIN_REFRESH_INTERVAL_MS - (SystemClock.uptimeMillis() - lastInvalidateAt) + handler.postDelayed(refresh, wait.coerceAtLeast(0L)) + } + + /// Sends a command and toasts its outcome. Success messages come from the + /// phone ("MeshMapper started in Passive mode."), so the driver gets the + /// same words Siri would speak. + protected fun runCommand( + kind: String, + mode: String? = null, + sessionId: String? = null, + fallbackSuccess: String, + ) { + CarStatusStore.sendCommand(kind, mode, sessionId) { outcome -> + val text = outcome.message ?: if (outcome.success) fallbackSuccess else "Command failed" + CarToast.makeText(carContext, text, CarToast.LENGTH_LONG).show() + } + } + + companion object { + const val MIN_REFRESH_INTERVAL_MS = 1000L + + /// Rows shown in a list while driving. Hosts guarantee at least six. + const val MAX_LIST_ROWS = 6 + } +} diff --git a/android/app/src/main/kotlin/net/meshmapper/app/auto/CarStatusStore.kt b/android/app/src/main/kotlin/net/meshmapper/app/auto/CarStatusStore.kt new file mode 100644 index 00000000..8458ca11 --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/auto/CarStatusStore.kt @@ -0,0 +1,126 @@ +package net.meshmapper.app.auto + +import android.os.Handler +import android.os.Looper +import io.flutter.plugin.common.MethodChannel +import java.util.UUID + +/// Process-wide meeting point between the Flutter engine and the car screens. +/// +/// The CarAppService and MainActivity's FlutterEngine have independent +/// lifecycles: the car can connect before the phone app is opened, and the +/// activity can go away while the head unit is still showing MeshMapper. This +/// object outlives both, so either side can arrive first and the other picks +/// up the current state when it attaches. All access is on the main thread, +/// which is where both MethodChannel callbacks and car Screen callbacks run. +object CarStatusStore { + fun interface Listener { + fun onCarStatusChanged() + } + + data class CommandOutcome(val success: Boolean, val message: String?) + + private val mainHandler = Handler(Looper.getMainLooper()) + private val listeners = LinkedHashSet() + private var channel: MethodChannel? = null + + /// Latest snapshot from Dart, or null before the first publish of this + /// engine. Cleared when the engine detaches so the car never shows a + /// session the phone has stopped reporting on. + var snapshot: CarSnapshot? = null + private set + + var carSessionActive: Boolean = false + private set + + val isAppAttached: Boolean + get() = channel != null + + fun addListener(listener: Listener) { + listeners.add(listener) + } + + fun removeListener(listener: Listener) { + listeners.remove(listener) + } + + // --- Flutter side ------------------------------------------------------- + + fun attachChannel(newChannel: MethodChannel) { + channel = newChannel + notifyListeners() + } + + fun detachChannel(oldChannel: MethodChannel) { + if (channel !== oldChannel) return + channel = null + snapshot = null + notifyListeners() + } + + fun updateSnapshot(newSnapshot: CarSnapshot) { + snapshot = newSnapshot + notifyListeners() + } + + // --- Car side ----------------------------------------------------------- + + fun setCarSessionActive(active: Boolean) { + if (carSessionActive == active) return + carSessionActive = active + channel?.invokeMethod("carSessionChanged", active) + } + + /// Sends a session command to Dart and reports its real outcome. + /// + /// The command carries a fresh id so a host-side retry of the same tap is + /// deduplicated by the Dart bridge, and the issue time lets Dart's shared + /// age rule refuse a transmit that arrives too late to be safe. + fun sendCommand( + kind: String, + mode: String? = null, + sessionId: String? = null, + onOutcome: (CommandOutcome) -> Unit, + ) { + val target = channel + if (target == null) { + onOutcome(CommandOutcome(false, "Open MeshMapper on your phone first")) + return + } + val arguments = hashMapOf( + "id" to UUID.randomUUID().toString(), + "source" to "androidAuto", + "kind" to kind, + "issuedAtMs" to System.currentTimeMillis(), + ) + if (mode != null) arguments["mode"] = mode + if (sessionId != null) arguments["sessionId"] = sessionId + target.invokeMethod("command", arguments, object : MethodChannel.Result { + override fun success(result: Any?) { + val map = result as? Map<*, *> + onOutcome( + CommandOutcome( + success = map?.get("success") == true, + message = (map?.get("message") as? String)?.takeIf { it.isNotBlank() }, + ), + ) + } + + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + onOutcome(CommandOutcome(false, errorMessage ?: "Command failed")) + } + + override fun notImplemented() { + onOutcome(CommandOutcome(false, "Update MeshMapper on your phone")) + } + }) + } + + private fun notifyListeners() { + if (Looper.myLooper() != Looper.getMainLooper()) { + mainHandler.post { notifyListeners() } + return + } + for (listener in listeners.toList()) listener.onCarStatusChanged() + } +} diff --git a/android/app/src/main/kotlin/net/meshmapper/app/auto/CarStatusText.kt b/android/app/src/main/kotlin/net/meshmapper/app/auto/CarStatusText.kt new file mode 100644 index 00000000..cdb73b77 --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/auto/CarStatusText.kt @@ -0,0 +1,58 @@ +package net.meshmapper.app.auto + +/// The words on the car map's status card, the phone's status bar and +/// counter chips in short form. +/// +/// Two lines only. The phone's phase detail ("Waiting for a square with no +/// recent mapping") is left out: it changes with every phase, and a card +/// sized to fit it widened and shrank every few seconds. +object CarStatusText { + data class Lines(val status: String, val chips: String?) + + private val DISCOVERY = Regex("discovery", RegexOption.IGNORE_CASE) + + fun lines(snapshot: CarSnapshot?, appAttached: Boolean, nowMs: Long = System.currentTimeMillis()): Lines { + if (!appAttached) { + return Lines("Open MeshMapper on your phone", null) + } + if (snapshot == null) return Lines("Connecting to MeshMapper…", null) + + val status = when { + !snapshot.isConnected -> "No companion connected" + // The phase alone, as the phone's bar shows it ("Next disc 13s"). + snapshot.sessionActive -> + snapshot.phaseTitle?.let(::shortPhase) ?: CarSnapshot.modeLabel(snapshot.mode) + snapshot.sessionStarting -> "Starting…" + // Idle with Start unavailable: the reason is the only explanation + // the car gives for the missing button. + !snapshot.canStart -> snapshot.startBlockedReason ?: "Not wardriving" + else -> "Not wardriving" + } + val seconds = countdownSeconds(snapshot, nowMs) + val chips = listOf( + "↑${snapshot.txCount}", + "↓${snapshot.rxCount}", + "◎${snapshot.discoveryCount}", + "⇅${snapshot.traceCount}", + "☁${snapshot.queueSize}", + ).joinToString(" ") + return Lines( + status = if (seconds != null) "$status ${seconds}s" else status, + chips = chips, + ) + } + + /// Whole seconds left on the running phase, rounded up so the car never + /// shows 0s while the phone still shows 1s. Null when nothing counts down. + fun countdownSeconds(snapshot: CarSnapshot?, nowMs: Long = System.currentTimeMillis()): Long? { + if (snapshot == null || !snapshot.sessionActive) return null + val endsAt = snapshot.phaseEndsAtMs ?: return null + val remainingMs = endsAt - nowMs + if (remainingMs <= 0) return null + return (remainingMs + 999) / 1000 + } + + /// "Discovery" becomes "disc" so the countdown fits on the line. + fun shortPhase(title: String): String = + DISCOVERY.replace(title) { if (it.value[0].isUpperCase()) "Disc" else "disc" } +} diff --git a/android/app/src/main/kotlin/net/meshmapper/app/auto/DashboardScreen.kt b/android/app/src/main/kotlin/net/meshmapper/app/auto/DashboardScreen.kt new file mode 100644 index 00000000..da0173c2 --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/auto/DashboardScreen.kt @@ -0,0 +1,203 @@ +package net.meshmapper.app.auto + +import androidx.car.app.CarContext +import androidx.car.app.model.Action +import androidx.car.app.model.ActionStrip +import androidx.car.app.model.CarColor +import androidx.car.app.model.Pane +import androidx.car.app.model.PaneTemplate +import androidx.car.app.model.Row +import androidx.car.app.model.Template + +/// The car's home screen: companion, session, counters and the last repeater +/// heard, with the one or two actions that make sense right now. +/// +/// Action choice follows the snapshot's own controls block, which Dart builds +/// from the same gates as the phone's buttons. Showing an action is only a +/// hint: Dart re-checks every tap, so a stale template can never transmit. +class DashboardScreen(carContext: CarContext) : CarStatusScreen(carContext) { + override fun onGetTemplate(): Template { + val snapshot = CarStatusStore.snapshot + val pane = Pane.Builder() + + when { + !CarStatusStore.isAppAttached -> pane + .addRow( + Row.Builder() + .setTitle("Open MeshMapper on your phone") + .addText("Wardriving runs on the phone. This screen mirrors it.") + .build(), + ) + snapshot == null -> pane.setLoading(true) + else -> buildDashboard(pane, snapshot) + } + + return PaneTemplate.Builder(pane.build()) + .setTitle("MeshMapper") + // Opened from the map's More button, so it needs its own way back: + // without this the car's hardware Back key was the only exit. + .setHeaderAction(Action.BACK) + .setActionStrip( + ActionStrip.Builder() + .addAction( + Action.Builder() + .setTitle("Heard") + .setOnClickListener { + screenManager.push(HeardScreen(carContext)) + } + .build(), + ) + .build(), + ) + .build() + } + + private fun buildDashboard(pane: Pane.Builder, snapshot: CarSnapshot) { + pane.addRow(connectionRow(snapshot)) + pane.addRow(sessionRow(snapshot)) + pane.addRow(countsRow(snapshot)) + snapshot.recentHeard.firstOrNull()?.let { pane.addRow(lastHeardRow(it)) } + + for (action in actionsFor(snapshot)) pane.addAction(action) + } + + private fun connectionRow(snapshot: CarSnapshot): Row { + val row = Row.Builder() + if (!snapshot.isConnected) { + row.setTitle("No companion connected") + row.addText("Connect a MeshCore companion in the phone app") + return row.build() + } + row.setTitle("Connected to ${snapshot.deviceName ?: "companion"}") + val details = listOfNotNull( + snapshot.batteryPercent?.let { "Battery $it%" }, + gpsLabel(snapshot.gpsStatus), + ) + if (details.isNotEmpty()) row.addText(details.joinToString(" · ")) + return row.build() + } + + private fun sessionRow(snapshot: CarSnapshot): Row { + val row = Row.Builder() + when { + snapshot.sessionActive -> { + val mode = CarSnapshot.modeLabel(snapshot.mode) + row.setTitle( + listOfNotNull("$mode mode", snapshot.phaseTitle).joinToString(" · "), + ) + val detail = listOfNotNull( + snapshot.phaseDetail, + snapshot.zoneCode?.let { "Zone $it" }, + ) + if (detail.isNotEmpty()) row.addText(detail.joinToString(" · ")) + } + snapshot.sessionStarting -> row.setTitle("Starting…") + else -> { + row.setTitle("Not wardriving") + val reason = if (snapshot.canStart) null else snapshot.startBlockedReason + row.addText(reason ?: "Ready to start") + } + } + return row.build() + } + + private fun countsRow(snapshot: CarSnapshot): Row { + val counts = buildList { + add("TX ${snapshot.txCount}") + add("RX ${snapshot.rxCount}") + if (snapshot.discoveryCount > 0) add("Disc ${snapshot.discoveryCount}") + if (snapshot.traceCount > 0) add("Trace ${snapshot.traceCount}") + } + val row = Row.Builder().setTitle(counts.joinToString(" · ")) + val heard = snapshot.uniqueRepeatersHeard + val detail = buildList { + add("$heard repeater${if (heard == 1) "" else "s"} heard") + if (snapshot.queueSize > 0) add("${snapshot.queueSize} waiting to upload") + } + row.addText(detail.joinToString(" · ")) + return row.build() + } + + private fun lastHeardRow(node: CarSnapshot.HeardNode): Row = + Row.Builder() + .setTitle("Last heard: ${node.label}") + .addText(HeardScreen.describe(node)) + .build() + + private fun actionsFor(snapshot: CarSnapshot): List { + if (snapshot.sessionStarting) return emptyList() + + if (snapshot.sessionActive) { + return buildList { + if (snapshot.canStop) { + add( + Action.Builder() + .setTitle("Stop") + .setBackgroundColor(CarColor.RED) + .setOnClickListener { + runCommand( + kind = "stopSession", + sessionId = snapshot.sessionId, + fallbackSuccess = "Stopped", + ) + } + .build(), + ) + } + if (snapshot.canManualPing) add(pingAction()) + } + } + + val modes = snapshot.availableStartModes + if (!snapshot.canStart || modes.isEmpty()) { + return if (snapshot.canManualPing) listOf(pingAction()) else emptyList() + } + // Passive never transmits, so it is the one-tap start. Transmit modes + // sit one step deeper, behind an explicit choice. + val primary = if ("passive" in modes) "passive" else modes.first() + return buildList { + add( + Action.Builder() + .setTitle("Start ${CarSnapshot.modeLabel(primary)}") + .setBackgroundColor(CarColor.GREEN) + .setOnClickListener { + runCommand( + kind = "startSession", + mode = primary, + fallbackSuccess = "Started", + ) + } + .build(), + ) + if (modes.size > 1) { + add( + Action.Builder() + .setTitle("Other modes") + .setOnClickListener { + screenManager.push(StartModeScreen(carContext)) + } + .build(), + ) + } else if (snapshot.canManualPing) { + add(pingAction()) + } + } + } + + private fun pingAction(): Action = + Action.Builder() + .setTitle("Ping") + .setOnClickListener { + runCommand(kind = "manualPing", fallbackSuccess = "Ping sent") + } + .build() + + private fun gpsLabel(status: String?): String? = when (status) { + null -> null + "locked" -> "GPS locked" + "searching" -> "GPS searching" + "disabled" -> "GPS off" + "permissionDenied" -> "No location permission" + else -> "GPS ${status.lowercase()}" + } +} diff --git a/android/app/src/main/kotlin/net/meshmapper/app/auto/HeardScreen.kt b/android/app/src/main/kotlin/net/meshmapper/app/auto/HeardScreen.kt new file mode 100644 index 00000000..c1c033d1 --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/auto/HeardScreen.kt @@ -0,0 +1,83 @@ +package net.meshmapper.app.auto + +import androidx.car.app.CarContext +import androidx.car.app.model.Action +import androidx.car.app.model.ItemList +import androidx.car.app.model.ListTemplate +import androidx.car.app.model.Row +import androidx.car.app.model.Template +import java.util.Locale +import kotlin.math.roundToInt + +/// Most recent repeater observations, newest first, as the snapshot lists +/// them. Read-only: there is nothing to tap on a row while driving. +class HeardScreen(carContext: CarContext) : CarStatusScreen(carContext) { + override fun onGetTemplate(): Template { + val snapshot = CarStatusStore.snapshot + val template = ListTemplate.Builder() + .setTitle("Recently heard") + .setHeaderAction(Action.BACK) + + if (snapshot == null) { + return template.setLoading(true).build() + } + + val items = ItemList.Builder() + .setNoItemsMessage("No repeaters heard yet") + for (node in snapshot.recentHeard.take(MAX_LIST_ROWS)) { + items.addItem( + Row.Builder() + .setTitle(node.label) + .addText(describe(node)) + .build(), + ) + } + return template.setSingleList(items.build()).build() + } + + companion object { + /// One-line summary of an observation: path, signal, distance, age. + fun describe( + node: CarSnapshot.HeardNode, + nowMs: Long = System.currentTimeMillis(), + includeDistance: Boolean = true, + ): String { + val parts = buildList { + kindLabel(node.kind)?.let { add(it) } + when { + node.direct -> add("Direct") + node.hopCount != null -> add("${node.hopCount} hop${if (node.hopCount == 1) "" else "s"}") + } + node.snr?.let { add(String.format(Locale.US, "SNR %.1f", it)) } + ?: node.rssi?.let { add("RSSI $it") } + if (includeDistance) node.distanceM?.let { add(distanceLabel(it)) } + if (node.observedAtMs > 0) add(ageLabel(nowMs - node.observedAtMs)) + } + return parts.joinToString(" · ") + } + + private fun kindLabel(kind: String?): String? = when (kind) { + "txEcho" -> "Echo" + "passiveRx" -> "RX" + "discovery" -> "Discovery" + "trace" -> "Trace" + else -> null + } + + private fun distanceLabel(meters: Double): String = + if (meters < 1000) { + "${meters.roundToInt()} m" + } else { + String.format(Locale.US, "%.1f km", meters / 1000) + } + + private fun ageLabel(ageMs: Long): String { + val seconds = (ageMs / 1000).coerceAtLeast(0) + return when { + seconds < 60 -> "just now" + seconds < 3600 -> "${seconds / 60} min ago" + else -> "${seconds / 3600} h ago" + } + } + } +} diff --git a/android/app/src/main/kotlin/net/meshmapper/app/auto/MeshMapperCarAppService.kt b/android/app/src/main/kotlin/net/meshmapper/app/auto/MeshMapperCarAppService.kt new file mode 100644 index 00000000..1db755f8 --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/auto/MeshMapperCarAppService.kt @@ -0,0 +1,48 @@ +package net.meshmapper.app.auto + +import android.content.Intent +import android.content.pm.ApplicationInfo +import androidx.car.app.CarAppService +import androidx.car.app.Screen +import androidx.car.app.Session +import androidx.car.app.validation.HostValidator +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner + +/// Entry point Android Auto binds to when MeshMapper is opened on the car. +/// +/// The car is a remote control and status display for the wardriving session +/// running on the phone. Nothing here talks to the radio, GPS or API: every +/// fact comes from the Dart snapshot and every action goes back through the +/// same admission the phone's buttons use. +class MeshMapperCarAppService : CarAppService() { + override fun createHostValidator(): HostValidator { + // Debug builds accept any host so the Desktop Head Unit works without + // extra setup. Release builds accept only the known Android Auto and + // Automotive hosts published by the car app library. + if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) { + return HostValidator.ALLOW_ALL_HOSTS_VALIDATOR + } + return HostValidator.Builder(applicationContext) + .addAllowedHosts(androidx.car.app.R.array.hosts_allowlist_sample) + .build() + } + + override fun onCreateSession(): Session = MeshMapperCarSession() +} + +class MeshMapperCarSession : Session() { + init { + lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onCreate(owner: LifecycleOwner) { + CarStatusStore.setCarSessionActive(true) + } + + override fun onDestroy(owner: LifecycleOwner) { + CarStatusStore.setCarSessionActive(false) + } + }) + } + + override fun onCreateScreen(intent: Intent): Screen = CarMapScreen(carContext) +} diff --git a/android/app/src/main/kotlin/net/meshmapper/app/auto/StartModeScreen.kt b/android/app/src/main/kotlin/net/meshmapper/app/auto/StartModeScreen.kt new file mode 100644 index 00000000..ab669216 --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/auto/StartModeScreen.kt @@ -0,0 +1,56 @@ +package net.meshmapper.app.auto + +import androidx.car.app.model.Action +import androidx.car.app.model.ItemList +import androidx.car.app.model.ListTemplate +import androidx.car.app.model.Row +import androidx.car.app.model.Template +import androidx.car.app.CarContext + +/// Lets the driver pick a transmit mode. Only modes the phone currently allows +/// are listed; the list empties itself (and the screen closes) once a session +/// is running, so a second tap cannot be aimed at a stale choice. +class StartModeScreen(carContext: CarContext) : CarStatusScreen(carContext) { + override fun onGetTemplate(): Template { + val snapshot = CarStatusStore.snapshot + val template = ListTemplate.Builder() + .setTitle("Start wardriving") + .setHeaderAction(Action.BACK) + + if (snapshot == null) return template.setLoading(true).build() + + val items = ItemList.Builder() + .setNoItemsMessage( + when { + snapshot.sessionActive -> "A session is already running" + else -> snapshot.startBlockedReason ?: "No modes available right now" + }, + ) + if (!snapshot.sessionActive && !snapshot.sessionStarting) { + for (mode in snapshot.availableStartModes) { + items.addItem( + Row.Builder() + .setTitle(CarSnapshot.modeLabel(mode)) + .addText(describe(mode)) + .setOnClickListener { + runCommand( + kind = "startSession", + mode = mode, + fallbackSuccess = "Started", + ) + finish() + } + .build(), + ) + } + } + return template.setSingleList(items.build()).build() + } + + private fun describe(mode: String): String = when (mode) { + "passive" -> "Listen only, never transmits" + "active" -> "Pings as you move and logs repeater echoes" + "hybrid" -> "Alternates discovery and active pings" + else -> "" + } +} diff --git a/android/app/src/main/res/drawable/ic_car_heading_up.xml b/android/app/src/main/res/drawable/ic_car_heading_up.xml new file mode 100644 index 00000000..350c7857 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_car_heading_up.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_car_layers.xml b/android/app/src/main/res/drawable/ic_car_layers.xml new file mode 100644 index 00000000..f440e609 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_car_layers.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_car_more.xml b/android/app/src/main/res/drawable/ic_car_more.xml new file mode 100644 index 00000000..cce4bda8 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_car_more.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_car_my_location.xml b/android/app/src/main/res/drawable/ic_car_my_location.xml new file mode 100644 index 00000000..b11da912 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_car_my_location.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_car_north_up.xml b/android/app/src/main/res/drawable/ic_car_north_up.xml new file mode 100644 index 00000000..a1ae4927 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_car_north_up.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_car_volume_up.xml b/android/app/src/main/res/drawable/ic_car_volume_up.xml new file mode 100644 index 00000000..65e42058 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_car_volume_up.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_car_zoom_in.xml b/android/app/src/main/res/drawable/ic_car_zoom_in.xml new file mode 100644 index 00000000..d108902d --- /dev/null +++ b/android/app/src/main/res/drawable/ic_car_zoom_in.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_car_zoom_out.xml b/android/app/src/main/res/drawable/ic_car_zoom_out.xml new file mode 100644 index 00000000..b0167c06 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_car_zoom_out.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/xml/automotive_app_desc.xml b/android/app/src/main/res/xml/automotive_app_desc.xml new file mode 100644 index 00000000..1c696010 --- /dev/null +++ b/android/app/src/main/res/xml/automotive_app_desc.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/lib/main.dart b/lib/main.dart index 9512a631..2e72377d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,6 +15,7 @@ import 'services/bluetooth/mobile_bluetooth.dart'; import 'services/bluetooth/web_bluetooth.dart'; import 'services/background_service.dart'; import 'services/app_intents/app_intent_bridge_service.dart'; +import 'services/android_auto/android_auto_bridge_service.dart'; import 'services/debug_file_logger.dart'; import 'services/offline_map_service.dart'; import 'services/watch/watch_bridge_service.dart'; @@ -35,6 +36,7 @@ void main() async { // provider finishes initializing and starts listening. WatchBridgeService.reserveCommandQueue(); AppIntentBridgeService.reserveCommandQueue(); + AndroidAutoBridgeService.reserveCommandQueue(); // Initialize debug logger (checks for ?debug=1 URL param on web) DebugLogger.initialize(); diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 553932a5..e0b9a566 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -29,6 +29,7 @@ import '../models/user_preferences.dart'; import '../services/airborne_release.dart'; import '../services/api_queue_service.dart'; import '../utils/coverage_refresh.dart'; +import '../utils/map_style_urls.dart'; import '../utils/mvt_cells.dart'; import '../utils/store_links.dart'; import '../services/api_service.dart'; @@ -76,6 +77,8 @@ import '../services/status/android_notification.dart'; import '../services/status/session_phase_resolver.dart'; import '../services/status/session_status.dart'; import '../services/status/session_status_resolver.dart'; +import '../services/android_auto/android_auto_bridge_service.dart'; +import '../services/android_auto/android_auto_map_projection.dart'; import '../services/live_activity/live_activity_service.dart'; import '../services/watch/watch_bridge_service.dart'; import '../services/watch/watch_models.dart'; @@ -282,6 +285,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final LiveActivityService _liveActivityService = LiveActivityService(); final WatchBridgeService _watchBridge = WatchBridgeService(); final AppIntentBridgeService _appIntentBridge = AppIntentBridgeService(); + final AndroidAutoBridgeService _androidAutoBridge = + AndroidAutoBridgeService(); bool _hasEverPairedWatch = false; /// Last position handed to the watch, kept so a dropped GPS fix leaves the @@ -2097,6 +2102,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { preflightKeyBuilder: _buildSiriSnapshotPreflightKey, immediate: immediate, ); + // Android Auto renders the same projection. The bridge returns at once + // unless a head unit is attached, so phones that never meet a car pay + // nothing here. + _androidAutoBridge.schedule( + _buildAndroidAutoSnapshotMap, + preflightKeyBuilder: _buildAndroidAutoPreflightKey, + immediate: immediate, + ); // The watch mirrors state even with no session running — otherwise you // could never start one from the wrist. _scheduleWatchSync(immediate: immediate); @@ -2631,6 +2644,76 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ).toMap(); } + /// The Siri projection, trimmed to what a car screen can show, plus the + /// map the car draws for itself. + /// + /// The head unit lists a handful of rows at most and names repeaters from + /// the observations themselves, so the repeater catalog and the long tail of + /// the heard history would only cost channel bandwidth on every refresh. + Map? _buildAndroidAutoSnapshotMap() { + final snapshot = _buildSiriSnapshotMap(); + if (snapshot == null) return null; + snapshot.remove('repeaters'); + final heard = snapshot['recentHeard']; + if (heard is List && heard.length > _androidAutoMaximumHeard) { + snapshot['recentHeard'] = heard.take(_androidAutoMaximumHeard).toList(); + } + final position = _currentPosition; + final fallback = _lastKnownPosition; + snapshot['map'] = AndroidAutoMapProjection.build( + latitude: position?.latitude ?? fallback?.lat, + longitude: position?.longitude ?? fallback?.lon, + heading: position?.heading, + style: mapStyleUrlFor(_preferences.mapStyle), + coverageHostCode: coverageHostCode, + mapTilesEnabled: _preferences.mapTilesEnabled, + gridSize: _preferences.coverageGridSize, + radioFilter: radioFilterQuery, + recentDays: coverageOverlayDays, + colorVisionType: _preferences.colorVisionType, + coverageOpacity: _preferences.coverageOverlayOpacity, + patchCells: coveragePatchCells.values, + patchVersion: _coveragePatchVersion, + ); + // The phone header's noise floor reading, coloured here so the car's + // badge uses exactly the phone's scale. + final noiseFloor = _currentNoiseFloor; + snapshot['header'] = { + 'noiseFloor': noiseFloor, + 'noiseFloorColor': noiseFloor == null + ? null + : PingColors.noiseFloorColor(noiseFloor.toDouble()).toARGB32(), + }; + return snapshot; + } + + /// [_buildSiriSnapshotPreflightKey] plus every input of the car's map. + /// + /// Position is bucketed to about ten metres and heading to ten degrees, so a + /// parked car with a jittering fix does not rebuild the snapshot each tick. + String _buildAndroidAutoPreflightKey() { + final position = _currentPosition; + final fallback = _lastKnownPosition; + final lat = position?.latitude ?? fallback?.lat; + final lon = position?.longitude ?? fallback?.lon; + final heading = position?.heading; + return jsonEncode([ + _buildSiriSnapshotPreflightKey(), + lat == null || !lat.isFinite ? null : (lat * 1e4).round(), + lon == null || !lon.isFinite ? null : (lon * 1e4).round(), + heading == null || !heading.isFinite ? null : heading ~/ 10, + _preferences.mapStyle, + _preferences.mapTilesEnabled, + _preferences.colorVisionType, + _preferences.coverageOverlayOpacity, + coverageOverlayContext, + _coveragePatchVersion, + _currentNoiseFloor, + ]); + } + + static const int _androidAutoMaximumHeard = 12; + /// Cheap semantic projection used to avoid rebuilding the bounded App Group /// snapshot on every provider notification. Log/catalog revisions cover the /// expensive collections; the remaining values mirror every scalar emitted @@ -2900,6 +2983,18 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); } + /// Runs a car tap through the same admission as every other surface. + /// + /// Unlike the watch this awaits the real outcome: the car shows a toast for + /// the result, and a driver should hear "Could not start" rather than see a + /// Start that silently did nothing. + Future _handleAndroidAutoCommand( + ExternalSessionCommand command, + ) async { + final admission = admitExternalSessionCommand(command); + return executeExternalSessionCommand(command, admission); + } + Future _connectToLastCompanion( AppIntentCommand command, ) async { @@ -3634,6 +3729,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_appIntentBridge.isSupportedPlatform) { _appIntentBridge.attachCommandHandler(_handleSiriCommand); } + if (_androidAutoBridge.isSupportedPlatform) { + _androidAutoBridge.attach( + commandHandler: _handleAndroidAutoCommand, + // A car that just connected has nothing on screen yet. Push the + // current state now rather than waiting for the next notify. + onSessionChanged: (active) { + if (active) _scheduleLiveActivitySync(immediate: true); + }, + ); + } // Initialize debug logging (enabled by default, respects user preference) await _initDebugLogs(); @@ -13577,6 +13682,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } _liveActivityService.dispose(); _appIntentBridge.dispose(); + _androidAutoBridge.dispose(); _watchBridge.diagnostics.removeListener(_handleWatchDiagnosticsChanged); _watchBridge.dispose(); WidgetsBinding.instance.removeObserver(this); diff --git a/lib/services/android_auto/android_auto_bridge_service.dart b/lib/services/android_auto/android_auto_bridge_service.dart new file mode 100644 index 00000000..99b3ef0e --- /dev/null +++ b/lib/services/android_auto/android_auto_bridge_service.dart @@ -0,0 +1,241 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import '../../utils/debug_logger_io.dart'; +import '../external_commands/external_command_models.dart'; +import '../external_surfaces/external_surface_publisher.dart'; + +typedef AndroidAutoCommandHandler = Future Function( + ExternalSessionCommand command, +); +typedef AndroidAutoSnapshotBuilder = Map? Function(); +typedef AndroidAutoPreflightKeyBuilder = String Function(); +typedef AndroidAutoSessionHandler = void Function(bool active); + +/// Flutter half of the Android Auto dashboard. +/// +/// The car screens live in MeshMapperCarAppService.kt and render the same +/// bounded snapshot Siri reads, so the head unit never learns a fact the +/// phone's other glance surfaces do not already publish. Snapshots only cross +/// the channel while a car session is open: with no head unit attached the +/// publisher stays disabled and the provider's notify pays one early return. +/// +/// Car taps arrive as [ExternalSessionCommand]s and go through the provider's +/// shared admission, so a stale car template can never transmit anything the +/// phone's own buttons would refuse. +class AndroidAutoBridgeService { + AndroidAutoBridgeService({ + @visibleForTesting MethodChannel? channel, + @visibleForTesting Duration debounceDelay = defaultDebounceDelay, + @visibleForTesting + Duration minimumNonUrgentInterval = defaultMinimumNonUrgentInterval, + @visibleForTesting bool? supportedPlatformOverride, + }) : _channel = channel ?? const MethodChannel(_channelName), + _supportedPlatformOverride = supportedPlatformOverride { + _publisher = + ExternalSurfacePublisher, Map>( + debounceDelay: debounceDelay, + minimumNonUrgentInterval: minimumNonUrgentInterval, + isEnabled: () => isSupportedPlatform && _carSessionActive, + preflightPolicy: ExternalSurfacePreflightPolicy.deduplicateAfterPublish, + payloadBuilder: (snapshot) => snapshot, + fingerprintBuilder: (snapshot) { + final semantic = Map.from(snapshot) + ..remove('updatedAtMs'); + return jsonEncode(semantic); + }, + urgencyKeyBuilder: (_) => null, + publish: (publication) async => await publishSnapshot(publication.payload) + ? const ExternalSurfacePublishResult.published() + : const ExternalSurfacePublishResult.rejected(), + restartDebounce: false, + immediateBypassesMinimumInterval: true, + ); + } + + static const String _channelName = 'meshmapper/android_auto'; + static const int _commandQueueDepth = 8; + static const int _maximumRememberedCommands = 64; + static const Duration defaultDebounceDelay = Duration(milliseconds: 250); + + /// Head units throttle template refreshes on their own; matching that floor + /// here keeps a busy session from queueing work the car will drop anyway. + static const Duration defaultMinimumNonUrgentInterval = Duration(seconds: 1); + + /// A car tap can land while the provider is still initializing. Reserve a + /// buffer so the relay is held instead of dropped, as the watch does. + static void reserveCommandQueue() { + if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return; + ServicesBinding.instance.channelBuffers + .resize(_channelName, _commandQueueDepth); + } + + final MethodChannel _channel; + final bool? _supportedPlatformOverride; + late final ExternalSurfacePublisher, + Map> _publisher; + AndroidAutoCommandHandler? _commandHandler; + AndroidAutoSessionHandler? _onSessionChanged; + final Map _commandOutcomes = {}; + final Map> _inFlightCommands = {}; + bool _carSessionActive = false; + bool _disposed = false; + + bool get isSupportedPlatform => + _supportedPlatformOverride ?? + (!kIsWeb && defaultTargetPlatform == TargetPlatform.android); + + bool get isCarSessionActive => _carSessionActive; + + /// Wires native calls to the provider and asks whether a car is already + /// attached, which happens when the head unit started the process. + void attach({ + required AndroidAutoCommandHandler commandHandler, + required AndroidAutoSessionHandler onSessionChanged, + }) { + _commandHandler = commandHandler; + _onSessionChanged = onSessionChanged; + if (!isSupportedPlatform) return; + _channel.setMethodCallHandler(_handleNativeCall); + unawaited(_queryInitialSession()); + } + + Future _queryInitialSession() async { + try { + final active = await _channel.invokeMethod('attach'); + if (active == true) _setCarSessionActive(true); + } on MissingPluginException { + // Engine without the native half (tests, add-to-app). Nothing to mirror. + } on PlatformException catch (error) { + debugError('[CAR] Attach failed: ${error.code}: ${error.message}'); + } + } + + Future _handleNativeCall(MethodCall call) async { + switch (call.method) { + case 'carSessionChanged': + _setCarSessionActive(call.arguments == true); + return null; + case 'command': + return _handleCommand(call.arguments); + default: + return null; + } + } + + void _setCarSessionActive(bool active) { + if (_disposed || _carSessionActive == active) return; + _carSessionActive = active; + debugLog('[CAR] Car session ${active ? 'opened' : 'closed'}'); + // A new head unit has none of the earlier screens' state, so the next + // build must not be deduplicated against what a previous car was sent. + _publisher.resetPublishedState(); + _onSessionChanged?.call(active); + } + + Future _handleCommand(Object? arguments) async { + if (arguments is! Map) return _refusal('Malformed command').toMap(); + final command = ExternalSessionCommand.fromMap( + arguments, + expectedSource: ExternalCommandSource.androidAuto, + ); + if (command == null) return _refusal('Malformed command').toMap(); + + final previous = _commandOutcomes[command.id]; + if (previous != null) return previous.toMap(commandId: command.id); + final inFlight = _inFlightCommands[command.id]; + if (inFlight != null) { + return (await inFlight).toMap(commandId: command.id); + } + + final handler = _commandHandler; + if (handler == null) { + debugWarn('[CAR] ${command.kind.name}: refused, no handler attached'); + return _refusal('App not ready').toMap(commandId: command.id); + } + + debugLog('[CAR] ${command.kind.name}' + '${command.mode == null ? '' : ' (${command.mode})'}: received'); + final execution = _executeCommand(handler, command); + _inFlightCommands[command.id] = execution; + try { + final completion = await execution; + _remember(command.id, completion); + debugLog('[CAR] ${command.kind.name}: ' + '${completion.disposition.name} success=${completion.success}'); + return completion.toMap(commandId: command.id); + } finally { + if (identical(_inFlightCommands[command.id], execution)) { + _inFlightCommands.remove(command.id); + } + } + } + + Future _executeCommand( + AndroidAutoCommandHandler handler, + ExternalSessionCommand command, + ) async { + try { + return await handler(command); + } catch (error) { + debugError('[CAR] ${command.kind.name} failed: $error'); + return _refusal('Command failed'); + } + } + + Future publishSnapshot(Map snapshot) async { + if (!isSupportedPlatform || _disposed || !_carSessionActive) return false; + try { + await _channel.invokeMethod('publishSnapshot', snapshot); + return true; + } on MissingPluginException { + return false; + } on PlatformException catch (error) { + debugError( + '[CAR] Snapshot publish failed: ${error.code}: ${error.message}', + ); + return false; + } + } + + void schedule( + AndroidAutoSnapshotBuilder builder, { + required AndroidAutoPreflightKeyBuilder preflightKeyBuilder, + bool immediate = false, + }) { + if (!isSupportedPlatform || _disposed || !_carSessionActive) return; + _publisher.schedule( + builder, + preflightKeyBuilder: preflightKeyBuilder, + immediate: immediate, + ); + } + + void _remember(String id, ExternalCommandCompletion completion) { + _commandOutcomes[id] = completion; + while (_commandOutcomes.length > _maximumRememberedCommands) { + _commandOutcomes.remove(_commandOutcomes.keys.first); + } + } + + static ExternalCommandCompletion _refusal(String message) => + ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: ExternalCommandReason.other(message), + ); + + void dispose() { + if (_disposed) return; + _disposed = true; + _commandHandler = null; + _onSessionChanged = null; + _commandOutcomes.clear(); + _inFlightCommands.clear(); + _publisher.dispose(); + if (isSupportedPlatform) _channel.setMethodCallHandler(null); + } +} diff --git a/lib/services/android_auto/android_auto_map_projection.dart b/lib/services/android_auto/android_auto_map_projection.dart new file mode 100644 index 00000000..61411992 --- /dev/null +++ b/lib/services/android_auto/android_auto_map_projection.dart @@ -0,0 +1,78 @@ +import '../../utils/coverage_tile_palette.dart'; +import '../../utils/coverage_tile_url.dart'; +import '../../utils/mvt_cells.dart'; + +/// The map half of the Android Auto snapshot. +/// +/// The car draws its own MapLibre map (MeshMapperCarMapRenderer.kt), so it +/// needs everything the phone's map widget reads to paint the same picture: +/// where the driver is, the base style, the coverage tile source and palette, +/// and the session's freshly patched cells. Everything that encodes a rule +/// (the tile URL, the colour table) is resolved here, in Dart, so the car can +/// never drift from the phone by re-deriving it. +class AndroidAutoMapProjection { + AndroidAutoMapProjection._(); + + /// Cells sent per snapshot. The patch holds up to 5000 and the snapshot is + /// re-sent on every change, so only the newest cells, the ones a driver is + /// actually passing, cross the channel. + static const int maximumPatchCells = 400; + + static Map build({ + required double? latitude, + required double? longitude, + required double? heading, + required String style, + required String? coverageHostCode, + required bool mapTilesEnabled, + required int gridSize, + required Map? radioFilter, + required int? recentDays, + required String colorVisionType, + required double coverageOpacity, + required Iterable patchCells, + required int patchVersion, + }) { + final host = coverageHostCode?.trim(); + final hasCoverage = mapTilesEnabled && host != null && host.isNotEmpty; + final validPosition = latitude != null && + longitude != null && + latitude.isFinite && + longitude.isFinite; + final validHeading = heading != null && heading.isFinite && heading >= 0; + + return { + if (validPosition) 'lat': latitude, + if (validPosition) 'lon': longitude, + if (validPosition && validHeading) 'heading': heading, + 'style': style, + 'coverageTiles': hasCoverage + ? coverageTileUrlTemplate( + coverageHostCode: host, + gridSize: gridSize, + radioFilter: radioFilter, + recentDays: recentDays, + ) + : null, + 'coverageOpacity': coverageOpacity.clamp(0.0, 1.0), + // [fill, border] for st 1..6, in order. + 'palette': [ + for (var st = 1; st <= 6; st++) + CoverageTilePalette.colorsForStatus(colorVisionType, st), + ], + 'patchVersion': patchVersion, + 'patch': hasCoverage ? _patch(patchCells, gridSize) : const [], + }; + } + + /// `[lat0, lon0, latStep, lonStep, st]` per cell, newest last. The patch map + /// re-inserts a cell when it changes, so its iteration order is age order. + static List> _patch(Iterable cells, int gridSize) { + final all = cells.toList(growable: false); + final start = + all.length > maximumPatchCells ? all.length - maximumPatchCells : 0; + return [ + for (final cell in all.skip(start)) [...cell.bounds(gridSize), cell.st], + ]; + } +} diff --git a/lib/services/external_commands/external_command_models.dart b/lib/services/external_commands/external_command_models.dart index 81841973..11c83b57 100644 --- a/lib/services/external_commands/external_command_models.dart +++ b/lib/services/external_commands/external_command_models.dart @@ -2,7 +2,8 @@ import 'package:flutter/foundation.dart'; enum ExternalCommandSource { watch, - siri; + siri, + androidAuto; static ExternalCommandSource? fromWire(String value) { for (final source in values) { diff --git a/lib/utils/coverage_tile_url.dart b/lib/utils/coverage_tile_url.dart new file mode 100644 index 00000000..ac631b8c --- /dev/null +++ b/lib/utils/coverage_tile_url.dart @@ -0,0 +1,22 @@ +import 'mvt_cells.dart'; + +/// The vector coverage tile URL template for a coverage host. +/// +/// Shared by the phone map's overlay and the Android Auto map so both paint +/// the same grid, radio preset and time window. [radioFilter] is the +/// provider's `radioFilterQuery` (f_freq, f_bw, f_sf; never f_cr). Its values +/// are digits and dots, so nothing needs encoding. +String coverageTileUrlTemplate({ + required String coverageHostCode, + required int gridSize, + Map? radioFilter, + int? recentDays, +}) { + final zone = coverageHostCode.toLowerCase(); + final recentSuffix = recentDays == null ? '' : '&f_days=$recentDays'; + final radioSuffix = radioFilter == null + ? '' + : '&f_freq=${radioFilter['f_freq']}&f_bw=${radioFilter['f_bw']}&f_sf=${radioFilter['f_sf']}'; + return 'https://$zone.meshmapper.net/vector_tile.php?z={z}&x={x}&y={y}' + '&gsize=$gridSize&cf=$kCoverageTileFormat$radioSuffix$recentSuffix'; +} diff --git a/lib/utils/map_style_urls.dart b/lib/utils/map_style_urls.dart new file mode 100644 index 00000000..0bf91861 --- /dev/null +++ b/lib/utils/map_style_urls.dart @@ -0,0 +1,28 @@ +// MapLibre base styles, keyed by the stored `UserPreferences.mapStyle` name. +// Shared by the phone map (`MapStyleExtension.styleUrl`) and the Android Auto +// map so both draw the style the user picked. + +/// Satellite style as inline MapLibre style JSON (ArcGIS raster source). +/// The `glyphs` URL is required because our native symbol layers +/// (repeater cluster count, individual repeater hex IDs, distance labels) +/// use `textField`, and MapLibre iOS wedges its resource loader with +/// NSURLError -1002 if it tries to resolve glyphs against a style that +/// doesn't declare a glyphs URL. +const satelliteStyleJson = + '{"version":8,"glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","sources":{"satellite":{"type":"raster","tiles":["https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"],"tileSize":256,"maxzoom":17}},"layers":[{"id":"satellite-layer","type":"raster","source":"satellite"}]}'; + +/// Style URL, or inline style JSON for satellite, for a stored style name. +/// Unknown names fall back to Liberty, the default for new users. +String mapStyleUrlFor(String styleName) { + switch (styleName) { + case 'dark': + return 'https://tiles.openfreemap.org/styles/dark'; + case 'light': + return 'https://tiles.openfreemap.org/styles/bright'; + case 'satellite': + return satelliteStyleJson; + case 'liberty': + default: + return 'https://tiles.openfreemap.org/styles/liberty'; + } +} diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 919b758e..fa330104 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -23,6 +23,8 @@ import '../services/repeater_admin/repeater_admin_models.dart'; import '../utils/async_callback_boundary.dart'; import '../utils/coverage_summary.dart'; import '../utils/coverage_tile_palette.dart'; +import '../utils/coverage_tile_url.dart'; +import '../utils/map_style_urls.dart'; import '../utils/cluster_spread.dart'; import '../utils/coalesced_async_runner.dart'; import '../utils/debug_logger_io.dart'; @@ -44,15 +46,6 @@ import 'repeater_id_chip.dart'; import 'repeater_scope_chips.dart'; import 'rx_path_chain.dart'; -/// Satellite style as inline MapLibre style JSON (ArcGIS raster source). -/// The `glyphs` URL is required because our native symbol layers -/// (repeater cluster count, individual repeater hex IDs, distance labels) -/// use `textField`, and MapLibre iOS wedges its resource loader with -/// NSURLError -1002 if it tries to resolve glyphs against a style that -/// doesn't declare a glyphs URL. -const _satelliteStyleJson = - '{"version":8,"glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","sources":{"satellite":{"type":"raster","tiles":["https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"],"tileSize":256,"maxzoom":17}},"layers":[{"id":"satellite-layer","type":"raster","source":"satellite"}]}'; - /// Default font stack used for all native text labels (textField property). /// Available in OpenFreeMap glyph sets (Liberty, Bright, Dark, Positron). const _defaultFontStack = ['Noto Sans Regular']; @@ -520,18 +513,7 @@ extension MapStyleExtension on MapStyle { } /// MapLibre style URL (or inline JSON for satellite) - String get styleUrl { - switch (this) { - case MapStyle.dark: - return 'https://tiles.openfreemap.org/styles/dark'; - case MapStyle.light: - return 'https://tiles.openfreemap.org/styles/bright'; - case MapStyle.liberty: - return 'https://tiles.openfreemap.org/styles/liberty'; - case MapStyle.satellite: - return _satelliteStyleJson; - } - } + String get styleUrl => mapStyleUrlFor(name); /// Whether this style can be packaged as an offline region. Satellite uses /// inline raster JSON which MapLibre's offline downloader doesn't support. @@ -3714,18 +3696,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final prefs = appState.preferences; // The region's site, or the group's when the zone belongs to one: the // group's site merges every member's coverage. - final zone = appState.coverageHostCode!.toLowerCase(); final gridSize = prefs.coverageGridSize; final recentDays = appState.coverageOverlayDays; - // Display every recent result. Only the Smart Pinging lookup filters types. - final recentSuffix = recentDays == null ? '' : '&f_days=$recentDays'; - // The preset filter (f_freq, f_bw, f_sf; never f_cr) so the overlay - // paints the preset the radio is on, or was last on. Values are digits - // and dots, so no encoding. Absent = the region's default layer. - final radioFilter = appState.radioFilterQuery; - final radioSuffix = radioFilter == null - ? '' - : '&f_freq=${radioFilter['f_freq']}&f_bw=${radioFilter['f_bw']}&f_sf=${radioFilter['f_sf']}'; // Replace, never stack: if an overlay is already up (double-triggered // add, resume racing a rebuild), tear it down first or the old @@ -3759,8 +3731,16 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // cf declares the tile format this build understands (blocks carrying // `k`); only send it while the decoder, the patch filter and the paint // all handle blocks. - final url = - 'https://$zone.meshmapper.net/vector_tile.php?z={z}&x={x}&y={y}&gsize=$gridSize&cf=$kCoverageTileFormat$radioSuffix$recentSuffix'; + // + // Every recent result is displayed (only the Smart Pinging lookup + // filters types), under the preset the radio is on, or was last on. + // Absent preset = the region's default layer. + final url = coverageTileUrlTemplate( + coverageHostCode: appState.coverageHostCode!, + gridSize: gridSize, + radioFilter: appState.radioFilterQuery, + recentDays: recentDays, + ); debugLog( '[MAP] Coverage overlay source: gsize=$gridSize preset=${appState.radioFilterKey ?? 'any'}'); // minzoom 2: the server answers z2 and up, zoomed-out tiles as blocks diff --git a/test/services/android_auto/android_auto_bridge_service_test.dart b/test/services/android_auto/android_auto_bridge_service_test.dart new file mode 100644 index 00000000..2b13ab44 --- /dev/null +++ b/test/services/android_auto/android_auto_bridge_service_test.dart @@ -0,0 +1,203 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/android_auto/android_auto_bridge_service.dart'; +import 'package:mesh_mapper/services/external_commands/external_command_models.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('AndroidAutoBridgeService', () { + late MethodChannel channel; + late AndroidAutoBridgeService bridge; + late List> published; + late bool attachReply; + + setUp(() { + channel = const MethodChannel('meshmapper/android_auto_test'); + published = []; + attachReply = false; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + switch (call.method) { + case 'attach': + return attachReply; + case 'publishSnapshot': + published.add(call.arguments as Map); + } + return null; + }); + bridge = AndroidAutoBridgeService( + channel: channel, + debounceDelay: const Duration(milliseconds: 5), + minimumNonUrgentInterval: const Duration(milliseconds: 25), + supportedPlatformOverride: true, + ); + }); + + tearDown(() { + bridge.dispose(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + Future sendNative(String method, [Object? arguments]) async { + final reply = await TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(MethodCall(method, arguments)), + null, + ); + return reply == null ? null : channel.codec.decodeEnvelope(reply); + } + + Future> sendCommand( + String id, { + String source = 'androidAuto', + String kind = 'startSession', + String? mode = 'passive', + }) async { + return await sendNative('command', { + 'id': id, + 'source': source, + 'kind': kind, + 'issuedAtMs': DateTime.now().millisecondsSinceEpoch, + if (mode != null) 'mode': mode, + }) as Map; + } + + void attach({ + Future Function(ExternalSessionCommand)? + handler, + List? sessions, + }) { + bridge.attach( + commandHandler: handler ?? + (_) async => const ExternalCommandCompletion( + success: true, + disposition: ExternalCommandDisposition.admitted, + ), + onSessionChanged: (active) => sessions?.add(active), + ); + } + + test('publishes nothing while no car is attached', () async { + attach(); + await pumpEventQueue(); + bridge.schedule( + () => {'value': 1}, + preflightKeyBuilder: () => '1', + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 20)); + expect(bridge.isCarSessionActive, isFalse); + expect(published, isEmpty); + }); + + test('a car already attached at startup opens the session', () async { + attachReply = true; + final sessions = []; + attach(sessions: sessions); + await pumpEventQueue(); + expect(bridge.isCarSessionActive, isTrue); + expect(sessions, [true]); + }); + + test('publishes while a car session is open and stops after it closes', + () async { + final sessions = []; + attach(sessions: sessions); + await sendNative('carSessionChanged', true); + bridge.schedule( + () => {'value': 1, 'updatedAtMs': 1}, + preflightKeyBuilder: () => '1', + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 20)); + expect(published, hasLength(1)); + expect(published.single['value'], 1); + + await sendNative('carSessionChanged', false); + bridge.schedule( + () => {'value': 2}, + preflightKeyBuilder: () => '2', + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 20)); + expect(published, hasLength(1)); + expect(sessions, [true, false]); + }); + + test('a reconnected car receives the unchanged snapshot again', () async { + attach(); + await sendNative('carSessionChanged', true); + Map build() => {'value': 1}; + bridge.schedule(build, preflightKeyBuilder: () => 'k', immediate: true); + await Future.delayed(const Duration(milliseconds: 20)); + + await sendNative('carSessionChanged', false); + await sendNative('carSessionChanged', true); + bridge.schedule(build, preflightKeyBuilder: () => 'k', immediate: true); + await Future.delayed(const Duration(milliseconds: 20)); + expect(published, hasLength(2)); + }); + + test('rejects commands that claim another source', () async { + var calls = 0; + attach(handler: (_) async { + calls++; + return const ExternalCommandCompletion( + success: true, + disposition: ExternalCommandDisposition.admitted, + ); + }); + final reply = await sendCommand('a', source: 'siri'); + expect(reply['success'], isFalse); + expect(reply['message'], 'Malformed command'); + expect(calls, 0); + }); + + test('forwards a car command and deduplicates its id', () async { + final received = []; + final released = Completer(); + attach(handler: (command) async { + received.add(command); + await released.future; + return const ExternalCommandCompletion( + success: true, + disposition: ExternalCommandDisposition.admitted, + message: ExternalCommandReason.other('Started'), + ); + }); + + final first = sendCommand('car-1'); + final duplicate = sendCommand('car-1'); + await pumpEventQueue(); + released.complete(); + + final replies = await Future.wait([first, duplicate]); + expect(received, hasLength(1)); + expect(received.single.source, ExternalCommandSource.androidAuto); + expect(received.single.kind, ExternalSessionCommandKind.startSession); + expect(received.single.mode, 'passive'); + for (final reply in replies) { + expect(reply['id'], 'car-1'); + expect(reply['success'], isTrue); + expect(reply['message'], 'Started'); + } + + final repeat = await sendCommand('car-1'); + expect(repeat['message'], 'Started'); + expect(received, hasLength(1)); + }); + + test('a throwing handler becomes a refusal', () async { + attach(handler: (_) async => throw StateError('boom')); + final reply = await sendCommand('car-2', kind: 'manualPing', mode: null); + expect(reply['success'], isFalse); + expect(reply['message'], 'Command failed'); + }); + }); +} diff --git a/test/services/android_auto/android_auto_map_projection_test.dart b/test/services/android_auto/android_auto_map_projection_test.dart new file mode 100644 index 00000000..927df992 --- /dev/null +++ b/test/services/android_auto/android_auto_map_projection_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/android_auto/android_auto_map_projection.dart'; +import 'package:mesh_mapper/utils/coverage_tile_url.dart'; +import 'package:mesh_mapper/utils/map_style_urls.dart'; +import 'package:mesh_mapper/utils/mvt_cells.dart'; + +void main() { + Map build({ + double? lat = 45.4, + double? lon = -75.7, + double? heading = 90, + String? host = 'YOW', + bool tiles = true, + Map? radio, + int? days, + Iterable cells = const [], + }) => + AndroidAutoMapProjection.build( + latitude: lat, + longitude: lon, + heading: heading, + style: mapStyleUrlFor('liberty'), + coverageHostCode: host, + mapTilesEnabled: tiles, + gridSize: 300, + radioFilter: radio, + recentDays: days, + colorVisionType: 'none', + coverageOpacity: 0.7, + patchCells: cells, + patchVersion: 3, + ); + + test('coverage URL matches the phone overlay template', () { + expect( + coverageTileUrlTemplate( + coverageHostCode: 'YOW', + gridSize: 100, + radioFilter: {'f_freq': '910.525', 'f_bw': '62.5', 'f_sf': '7'}, + recentDays: 14, + ), + 'https://yow.meshmapper.net/vector_tile.php?z={z}&x={x}&y={y}' + '&gsize=100&cf=$kCoverageTileFormat' + '&f_freq=910.525&f_bw=62.5&f_sf=7&f_days=14', + ); + }); + + test('carries position, style, palette and tile source', () { + final map = build(); + expect(map['lat'], 45.4); + expect(map['lon'], -75.7); + expect(map['heading'], 90); + expect(map['style'], 'https://tiles.openfreemap.org/styles/liberty'); + expect(map['coverageTiles'], startsWith('https://yow.meshmapper.net/')); + expect(map['coverageOpacity'], 0.7); + expect(map['palette'], hasLength(6)); + expect((map['palette'] as List).first, ['#1e7e34', '#14522d']); + expect(map['patchVersion'], 3); + }); + + test('omits an unknown position and an invalid heading', () { + final map = build(lat: null, lon: null); + expect(map.containsKey('lat'), isFalse); + expect(map.containsKey('heading'), isFalse); + expect(build(heading: -1).containsKey('heading'), isFalse); + }); + + test('no coverage source without a host or with tiles turned off', () { + expect(build(host: null)['coverageTiles'], isNull); + expect(build(host: ' ')['coverageTiles'], isNull); + expect(build(tiles: false)['coverageTiles'], isNull); + expect( + build(tiles: false, cells: const [CoverageCell(1, 0, 0, 1)])['patch'], + isEmpty); + }); + + test('patch sends only the newest cells, as rectangles with status', () { + final cells = [ + for (var n = 0; n < AndroidAutoMapProjection.maximumPatchCells + 5; n++) + CoverageCell(n, n, n, (n % 6) + 1), + ]; + final patch = build(cells: cells)['patch'] as List; + expect(patch, hasLength(AndroidAutoMapProjection.maximumPatchCells)); + final last = cells.last; + expect(patch.last, [...last.bounds(300), last.st]); + }); +}