From 6f402c008166652923fd91e38949047c3a52e1cf Mon Sep 17 00:00:00 2001 From: ekodikara <8066214+ekodikara@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:38:39 +1100 Subject: [PATCH 1/2] Add Android Auto support Shows the phone's Map tab on Android Auto head units. The car draws its own MapLibre map with the user's style, the coverage overlay, the session's patched cells, heard repeaters and the position arrow. It follows heading up, with a status card for the phase countdown and counters, and a box for the noise floor and companion battery. The car is a remote for the session on the phone. It renders the Siri snapshot projection plus a map block over a new meshmapper/android_auto channel, published only while a car is connected, and every tap goes through the same admission as the phone buttons, the watch and Siri. The coverage tile URL and the map style URLs move into shared helpers so the phone map and the car map stay in step. --- DEVELOPMENT.md | 84 ++ README.md | 2 + android/app/build.gradle.kts | 4 + android/app/src/main/AndroidManifest.xml | 26 + .../kotlin/net/meshmapper/app/MainActivity.kt | 4 + .../app/MeshMapperAndroidAutoService.kt | 48 ++ .../net/meshmapper/app/auto/CarMapRenderer.kt | 772 ++++++++++++++++++ .../net/meshmapper/app/auto/CarMapScreen.kt | 134 +++ .../net/meshmapper/app/auto/CarSnapshot.kt | 197 +++++ .../meshmapper/app/auto/CarStatusScreen.kt | 75 ++ .../net/meshmapper/app/auto/CarStatusStore.kt | 126 +++ .../net/meshmapper/app/auto/CarStatusText.kt | 58 ++ .../meshmapper/app/auto/DashboardScreen.kt | 201 +++++ .../net/meshmapper/app/auto/HeardScreen.kt | 83 ++ .../app/auto/MeshMapperCarAppService.kt | 48 ++ .../meshmapper/app/auto/StartModeScreen.kt | 56 ++ .../main/res/drawable/ic_car_heading_up.xml | 11 + .../src/main/res/drawable/ic_car_layers.xml | 11 + .../app/src/main/res/drawable/ic_car_more.xml | 11 + .../main/res/drawable/ic_car_my_location.xml | 11 + .../src/main/res/drawable/ic_car_north_up.xml | 17 + .../main/res/drawable/ic_car_volume_up.xml | 12 + .../src/main/res/drawable/ic_car_zoom_in.xml | 11 + .../src/main/res/drawable/ic_car_zoom_out.xml | 11 + .../src/main/res/xml/automotive_app_desc.xml | 6 + lib/main.dart | 2 + lib/providers/app_state_provider.dart | 106 +++ .../android_auto_bridge_service.dart | 241 ++++++ .../android_auto_map_projection.dart | 78 ++ .../external_command_models.dart | 3 +- lib/utils/coverage_tile_url.dart | 22 + lib/utils/map_style_urls.dart | 28 + lib/widgets/map_widget.dart | 46 +- .../android_auto_bridge_service_test.dart | 203 +++++ .../android_auto_map_projection_test.dart | 87 ++ 35 files changed, 2801 insertions(+), 34 deletions(-) create mode 100644 android/app/src/main/kotlin/net/meshmapper/app/MeshMapperAndroidAutoService.kt create mode 100644 android/app/src/main/kotlin/net/meshmapper/app/auto/CarMapRenderer.kt create mode 100644 android/app/src/main/kotlin/net/meshmapper/app/auto/CarMapScreen.kt create mode 100644 android/app/src/main/kotlin/net/meshmapper/app/auto/CarSnapshot.kt create mode 100644 android/app/src/main/kotlin/net/meshmapper/app/auto/CarStatusScreen.kt create mode 100644 android/app/src/main/kotlin/net/meshmapper/app/auto/CarStatusStore.kt create mode 100644 android/app/src/main/kotlin/net/meshmapper/app/auto/CarStatusText.kt create mode 100644 android/app/src/main/kotlin/net/meshmapper/app/auto/DashboardScreen.kt create mode 100644 android/app/src/main/kotlin/net/meshmapper/app/auto/HeardScreen.kt create mode 100644 android/app/src/main/kotlin/net/meshmapper/app/auto/MeshMapperCarAppService.kt create mode 100644 android/app/src/main/kotlin/net/meshmapper/app/auto/StartModeScreen.kt create mode 100644 android/app/src/main/res/drawable/ic_car_heading_up.xml create mode 100644 android/app/src/main/res/drawable/ic_car_layers.xml create mode 100644 android/app/src/main/res/drawable/ic_car_more.xml create mode 100644 android/app/src/main/res/drawable/ic_car_my_location.xml create mode 100644 android/app/src/main/res/drawable/ic_car_north_up.xml create mode 100644 android/app/src/main/res/drawable/ic_car_volume_up.xml create mode 100644 android/app/src/main/res/drawable/ic_car_zoom_in.xml create mode 100644 android/app/src/main/res/drawable/ic_car_zoom_out.xml create mode 100644 android/app/src/main/res/xml/automotive_app_desc.xml create mode 100644 lib/services/android_auto/android_auto_bridge_service.dart create mode 100644 lib/services/android_auto/android_auto_map_projection.dart create mode 100644 lib/utils/coverage_tile_url.dart create mode 100644 lib/utils/map_style_urls.dart create mode 100644 test/services/android_auto/android_auto_bridge_service_test.dart create mode 100644 test/services/android_auto/android_auto_map_projection_test.dart diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 8f055234..36df44cc 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. @@ -2598,6 +2681,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..52a89ddb --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/auto/CarMapRenderer.kt @@ -0,0 +1,772 @@ +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 in small type: + /// the phase with its countdown, the counters, then the detail line. + 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"))) + addView(cardLine(context, 13f * CARD_SCALE, bold = false, color = Color.parseColor("#94A3B8"))) + } + + 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, lines.detail) + 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..02a310dc --- /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. +object CarStatusText { + data class Lines(val status: String, val chips: String?, val detail: 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, "This screen mirrors the phone") + } + if (snapshot == null) return Lines("Connecting to MeshMapper…", null, 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…" + else -> "Not wardriving" + } + val seconds = countdownSeconds(snapshot, nowMs) + val chips = listOf( + "↑${snapshot.txCount}", + "↓${snapshot.rxCount}", + "◎${snapshot.discoveryCount}", + "⇅${snapshot.traceCount}", + "☁${snapshot.queueSize}", + ).joinToString(" ") + val detail = when { + snapshot.sessionActive -> snapshot.phaseDetail + !snapshot.isConnected -> null + !snapshot.canStart -> snapshot.startBlockedReason + else -> snapshot.deviceName + } + return Lines( + status = if (seconds != null) "$status ${seconds}s" else status, + chips = chips, + detail = detail, + ) + } + + /// 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..68621833 --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/auto/DashboardScreen.kt @@ -0,0 +1,201 @@ +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") + .setHeaderAction(Action.APP_ICON) + .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]); + }); +} From 13fb5e548768e291a4e982590ebf85e323ba96d8 Mon Sep 17 00:00:00 2001 From: ekodikara <8066214+ekodikara@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:21:11 +1100 Subject: [PATCH 2/2] Keep the car status card steady and add a way back from More The status card drops the phase detail line, which changed with every phase and made the card grow and shrink. The More screen gets a back arrow, so the hardware back key is no longer the only way to the map. --- .../net/meshmapper/app/auto/CarMapRenderer.kt | 7 +++---- .../net/meshmapper/app/auto/CarStatusText.kt | 20 +++++++++---------- .../meshmapper/app/auto/DashboardScreen.kt | 4 +++- 3 files changed, 16 insertions(+), 15 deletions(-) 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 index 52a89ddb..7523b8c7 100644 --- a/android/app/src/main/kotlin/net/meshmapper/app/auto/CarMapRenderer.kt +++ b/android/app/src/main/kotlin/net/meshmapper/app/auto/CarMapRenderer.kt @@ -579,8 +579,8 @@ class CarMapRenderer(private val carContext: CarContext) : SurfaceCallback { return bitmap } - /// The phone's status bar and counter chips, drawn top left in small type: - /// the phase with its countdown, the counters, then the detail line. + /// 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 @@ -593,7 +593,6 @@ class CarMapRenderer(private val carContext: CarContext) : SurfaceCallback { } addView(cardLine(context, 17f * CARD_SCALE, bold = true, color = Color.WHITE)) addView(cardLine(context, 15f * CARD_SCALE, bold = false, color = Color.parseColor("#CBD5E1"))) - addView(cardLine(context, 13f * CARD_SCALE, bold = false, color = Color.parseColor("#94A3B8"))) } private fun cardLine(context: android.content.Context, sizeSp: Float, bold: Boolean, color: Int): TextView = @@ -635,7 +634,7 @@ class CarMapRenderer(private val carContext: CarContext) : SurfaceCallback { fun refreshStatus() { val card = statusCard ?: return val lines = CarStatusText.lines(CarStatusStore.snapshot, CarStatusStore.isAppAttached) - val values = listOf(lines.status, lines.chips, lines.detail) + val values = listOf(lines.status, lines.chips) values.forEachIndexed { index, value -> val view = card.getChildAt(index) as TextView view.text = value ?: "" 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 index 02a310dc..cdb73b77 100644 --- a/android/app/src/main/kotlin/net/meshmapper/app/auto/CarStatusText.kt +++ b/android/app/src/main/kotlin/net/meshmapper/app/auto/CarStatusText.kt @@ -2,16 +2,20 @@ 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?, val detail: String?) + 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, "This screen mirrors the phone") + return Lines("Open MeshMapper on your phone", null) } - if (snapshot == null) return Lines("Connecting to MeshMapper…", null, null) + if (snapshot == null) return Lines("Connecting to MeshMapper…", null) val status = when { !snapshot.isConnected -> "No companion connected" @@ -19,6 +23,9 @@ object CarStatusText { 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) @@ -29,16 +36,9 @@ object CarStatusText { "⇅${snapshot.traceCount}", "☁${snapshot.queueSize}", ).joinToString(" ") - val detail = when { - snapshot.sessionActive -> snapshot.phaseDetail - !snapshot.isConnected -> null - !snapshot.canStart -> snapshot.startBlockedReason - else -> snapshot.deviceName - } return Lines( status = if (seconds != null) "$status ${seconds}s" else status, chips = chips, - detail = detail, ) } 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 index 68621833..da0173c2 100644 --- a/android/app/src/main/kotlin/net/meshmapper/app/auto/DashboardScreen.kt +++ b/android/app/src/main/kotlin/net/meshmapper/app/auto/DashboardScreen.kt @@ -34,7 +34,9 @@ class DashboardScreen(carContext: CarContext) : CarStatusScreen(carContext) { return PaneTemplate.Builder(pane.build()) .setTitle("MeshMapper") - .setHeaderAction(Action.APP_ICON) + // 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(