diff --git a/TODO.md b/TODO.md index 566940a..87fcc55 100644 --- a/TODO.md +++ b/TODO.md @@ -5,6 +5,29 @@ Engineering to-do list. The reviewer-facing open items live in ## Waiting on hardware +### On-device check of the full-screen V2X live map (added 2026-09-15) + +The live map moved out of the V2X Monitor's view-mode row into its own full-screen destination +(`V2xMapScreen`, route `v2x_map`), reached from the map button in that screen's header. Markers are +now cached and reused across updates instead of being rebuilt on every incoming message, and +SPATEM intersections are drawn as traffic lights at the position of the RSU's own CAM. All of that +compiles and the unit tests pass, but none of it has been seen with live traffic. + +Needs: the phone with the app, plus a CAM/DENM/SPATEM source - either the CiT One, or the OBU +ESP32-C5 with a second board or a real RSU transmitting. + +- [ ] Both hardware modes: tap the map button, confirm the map fills the screen (no status bar, no + bottom nav) and the back button returns to the V2X Monitor. +- [ ] Panning stays smooth while CAMs are arriving - this is what the marker reuse is for. Compare + against the old behaviour if it still judders. +- [ ] Touching the map stops it recentring; the location FAB resumes follow and lights up. +- [ ] A DENM shows the warning triangle, and a SPATEM intersection shows a traffic light with the + lamp matching the Dashboard's SignalCard for the same intersection. +- [ ] Near a real RSU: confirm the RSU is drawn once, as a traffic light, not as a CAM pin with a + light on top of it. If the RSU sends SPATEM but no CAM, the "signals not shown" note should + appear instead - worth knowing which of the two the HAW RSUs actually do. + + ### Over-the-air check of the GN lifetime fix (added 2026-09-11) `geonet.c` now writes GN lifetime `0x05` (1 s) instead of `0x83`, which decoded to 3200 s. Changed diff --git a/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt b/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt index 1727eb0..77f491b 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt @@ -26,6 +26,7 @@ import androidx.core.view.WindowCompat import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState @@ -40,6 +41,7 @@ import com.hawhamburg.micr0bu.ui.screens.MqttTopicViewerScreen import com.hawhamburg.micr0bu.ui.screens.RecordingScreen import com.hawhamburg.micr0bu.ui.screens.SensorScreen import com.hawhamburg.micr0bu.ui.screens.SessionLogScreen +import com.hawhamburg.micr0bu.ui.screens.V2xMapScreen import com.hawhamburg.micr0bu.ui.screens.MapScreen import com.hawhamburg.micr0bu.ui.screens.TripHistoryScreen import com.hawhamburg.micr0bu.ui.screens.TripReviewScreen @@ -104,6 +106,13 @@ class MainActivity : AppCompatActivity() { } val navController = rememberNavController() + // The V2X live map is a full-bleed destination: the app's own chrome would eat a + // third of the display on the one screen whose entire job is showing where things + // are relative to each other. It carries its own floating back button, and system + // back still works, so nothing becomes unreachable. + val currentBackStackEntry by navController.currentBackStackEntryAsState() + val isFullBleed = currentBackStackEntry?.destination?.route == Screen.V2xMap.route + val locationLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { permissions -> @@ -132,14 +141,16 @@ class MainActivity : AppCompatActivity() { Scaffold( topBar = { - StatusTopBar( - state = state, - mqttConnectionState = mqttConnectionState, - isEsp32 = obuHardware == ObuHardware.ESP32_C5, - usbSerialState = usbSerialState, - ) + if (!isFullBleed) { + StatusTopBar( + state = state, + mqttConnectionState = mqttConnectionState, + isEsp32 = obuHardware == ObuHardware.ESP32_C5, + usbSerialState = usbSerialState, + ) + } }, - bottomBar = { BottomNavBar(navController) }, + bottomBar = { if (!isFullBleed) BottomNavBar(navController) }, ) { innerPadding -> NavHost( navController = navController, @@ -253,7 +264,19 @@ class MainActivity : AppCompatActivity() { } composable(Screen.MqttViewer.route) { - MqttTopicViewerScreen(viewModel = mqttViewModel) + MqttTopicViewerScreen( + viewModel = mqttViewModel, + onOpenMap = { navController.navigate(Screen.V2xMap.route) }, + ) + } + composable(Screen.V2xMap.route) { + // Activity-scoped instance, like Connection below: a hiltViewModel() + // here would be scoped to this NavBackStackEntry and torn down on the + // way back out, taking the shared transport with it. + V2xMapScreen( + viewModel = mqttViewModel, + onBack = { navController.popBackStack() }, + ) } composable(Screen.Settings.route) { SettingsScreen( diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt index c44f205..36bc42c 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt @@ -34,6 +34,8 @@ sealed class Screen(val route: String, val labelRes: Int) { data object Connection : Screen("connection", R.string.nav_connection) data object Map : Screen("map", R.string.map_title) data object MqttViewer : Screen("mqtt_viewer", R.string.nav_v2x) + /** Full-screen V2X live map, opened from the V2X Monitor's map button. */ + data object V2xMap : Screen("v2x_map", R.string.v2x_map_title) // Phase A — Trip Recording data object TripHistory : Screen("trip_history", R.string.nav_trips) @@ -83,6 +85,7 @@ private fun Screen.ownsRoute(route: String?): Boolean { route == Screen.Map.route || route == Screen.Sensors.route Screen.Record -> route == Screen.Log.route + Screen.MqttViewer -> route == Screen.V2xMap.route else -> false } } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt index cf7e036..97586c2 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Circle import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.LinkOff +import androidx.compose.material.icons.filled.Map import androidx.compose.material.icons.filled.NotificationsActive import androidx.compose.material.icons.filled.VerticalAlignBottom import androidx.compose.material.icons.filled.Warning @@ -87,11 +88,15 @@ import java.util.Date import java.util.Locale /** - * View toggle for [TopicListPane]: decoded CAM/DENM traffic (LIST), the raw MQTT topic list - * (TOPICS, CiT One only - there is no broker on the ESP32-C5 path), or the V2X live map - * (MAP, Section 13). + * View toggle for [TopicListPane]: decoded CAM/DENM traffic (LIST) or the raw MQTT topic list + * (TOPICS, CiT One only - there is no broker on the ESP32-C5 path). + * + * The live map used to be a third mode here. It is now its own full-screen destination + * ([V2xMapScreen]), reached from the map button in this screen's header: sharing the screen with + * the alert panel and the TX cards left the map about a third of a phone display tall, which is + * not enough to see where anything is relative to anything else. */ -private enum class TopicViewMode { LIST, TOPICS, MAP } +private enum class TopicViewMode { LIST, TOPICS } private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) @@ -116,6 +121,7 @@ private val WarningRedBg = Color(0xFF3A0A0A) @Composable fun MqttTopicViewerScreen( viewModel: MqttViewModel = hiltViewModel(), + onOpenMap: () -> Unit = {}, ) { val connectionState by viewModel.connectionState.collectAsState() val topicMessages by viewModel.topicMessages.collectAsState() @@ -193,6 +199,17 @@ fun MqttTopicViewerScreen( Spacer(Modifier.weight(1f)) } + // Full-screen live map. In the header rather than in the view-mode row below, so it + // is reachable from the message detail pane too and does not move around as the + // available view modes change with the selected hardware. + IconButton(onClick = onOpenMap) { + Icon( + Icons.Default.Map, + contentDescription = stringResource(R.string.v2x_map_title), + tint = MaterialTheme.colorScheme.primary, + ) + } + ConnectionChip(effectiveState) Spacer(Modifier.width(2.dp)) IconButton( @@ -335,49 +352,38 @@ private fun TopicListPane( HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f)) } - // ── List / Topics / Map toggle ──────────────────────────────────────────────────── + // ── List / Topics toggle ────────────────────────────────────────────── // Decoded traffic is the default on BOTH hardware paths: what a tester wants to see is // the road users and hazards, not the transport that carried them. The raw MQTT topic // list stays one tap away on the CiT One path (Section 13 asks for the map "in addition - // to", not instead of, the topic list). It is hidden on the ESP32-C5 path, where there is - // no broker and `topics` is permanently empty. - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - ViewModeButton( - label = stringResource(R.string.mqtt_view_list), - selected = viewMode == TopicViewMode.LIST, - ) { viewMode = TopicViewMode.LIST } + // to", not instead of, the topic list). + // + // The whole row is hidden on the ESP32-C5 path: there is no broker there, `topics` is + // permanently empty, and a toggle offering a single choice is just noise. + if (!isEsp32) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ViewModeButton( + label = stringResource(R.string.mqtt_view_list), + selected = viewMode == TopicViewMode.LIST, + ) { viewMode = TopicViewMode.LIST } - if (!isEsp32) { ViewModeButton( label = stringResource(R.string.mqtt_view_topics), selected = viewMode == TopicViewMode.TOPICS, ) { viewMode = TopicViewMode.TOPICS } } - - ViewModeButton( - label = stringResource(R.string.mqtt_view_map), - selected = viewMode == TopicViewMode.MAP, - ) { viewMode = TopicViewMode.MAP } } - // ── Decoded traffic / raw topics / live map ─────────────────────────── + // ── Decoded traffic / raw topics ────────────────────────────────────── // TOPICS can still be the saved selection from a CiT One session after switching hardware // to the ESP32-C5, where that button no longer exists - fall back to the decoded list // rather than stranding the user on a pane they can't navigate away from. val shownMode = if (viewMode == TopicViewMode.TOPICS && isEsp32) TopicViewMode.LIST else viewMode - if (shownMode == TopicViewMode.MAP) { - V2xLiveMapView( - own = ownCamPosition, - remotes = remoteCamPositions, - alerts = useCaseAlerts, - denms = denmEvents, - modifier = Modifier.fillMaxSize(), - ) - } else if (shownMode == TopicViewMode.LIST) { + if (shownMode == TopicViewMode.LIST) { ReceivedCamPane( own = ownCamPosition, remotes = remoteCamPositions, diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/V2xLiveMapView.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/V2xLiveMapView.kt index 305d34f..ca4156d 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/V2xLiveMapView.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/V2xLiveMapView.kt @@ -1,12 +1,13 @@ package com.hawhamburg.micr0bu.ui.screens import android.content.Context +import android.graphics.drawable.Drawable +import android.view.MotionEvent import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.GpsOff @@ -30,25 +31,37 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import com.hawhamburg.micr0bu.R import com.hawhamburg.micr0bu.domain.cam.Cam import com.hawhamburg.micr0bu.domain.denm.DenmEvent +import com.hawhamburg.micr0bu.domain.spat.SpatIntersection import com.hawhamburg.micr0bu.domain.usecase.AlertLevel import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert import org.osmdroid.config.Configuration import org.osmdroid.tileprovider.tilesource.TileSourceFactory import org.osmdroid.util.GeoPoint +import org.osmdroid.views.CustomZoomButtonsController import org.osmdroid.views.MapView import org.osmdroid.views.overlay.Marker /** - * V2X Monitor live map view (Phase 03, Section 13) — plots the ego bike's own position plus - * every currently-tracked remote road user's last-known CAM position, in addition to (not - * replacing) the raw topic list already on this screen. Reuses the same osmdroid pattern as - * [MapScreen]; unlike that screen, this one has no phone-GNSS-only fallback because [own] here - * always reflects whichever ego source [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository] - * currently trusts (obu_gnss / phone GNSS / CAM-topic-own — see that class's KDoc). + * V2X Monitor live map (Phase 03, Section 13) — the map body behind [V2xMapScreen], plotting the + * ego bike's own position, every currently-tracked remote road user's last-known CAM position, + * every live hazard (DENM) and every signalised intersection heard over SPATEM. * - * Remote markers are colored by that station's most severe active alert level, if any, so a - * glance at the map shows not just "who's nearby" but "who's a warning right now" — the same - * severity coloring already used by [UseCaseAlertPanel]. + * Marker vocabulary, one shape per message type so the map reads without a legend: + * - CAM — teardrop pin, tinted by that station's most severe active alert level + * - DENM — hazard warning triangle + * - SPATEM — traffic light, with the lamp for the intersection's leading phase lit + * + * Unlike [MapScreen] this has no phone-GNSS-only fallback: [own] always reflects whichever ego + * source [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository] currently trusts (obu_gnss / + * phone GNSS / CAM-topic-own — see that class's KDoc). + * + * **Markers are reused across updates, not rebuilt.** CAMs arrive at up to 10 Hz per station, and + * every arrival recomposes this view; the previous version cleared the overlay list and rebuilt + * every Marker — decoding and mutating a fresh Drawable per marker per update — which is what + * made panning stutter under live traffic. Drawables are now loaded once per level/phase and + * shared (osmdroid sets the icon's bounds on each draw, so sharing one instance across markers is + * safe), and Marker objects are cached by key. The overlay list is still reordered each update, + * which costs nothing: it moves existing references, it does not allocate. */ @Composable fun V2xLiveMapView( @@ -56,6 +69,9 @@ fun V2xLiveMapView( remotes: Map, alerts: List, denms: List = emptyList(), + spats: List = emptyList(), + followOwn: Boolean = true, + onUserPanned: () -> Unit = {}, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -71,8 +87,17 @@ fun V2xLiveMapView( .mapValues { (_, a) -> a.maxByOrNull { it.alertLevel.ordinal }?.alertLevel } } + // Loaded once and shared by every marker that needs them. mutate() on the remote pin is still + // essential: without it all four tinted copies would share one ConstantState and the last + // tint applied would recolour every pin on the map. + val icons = remember(context) { MapIcons(context) } + val markers = remember { mutableMapOf() } + val mapViewRef = remember { mutableStateOf(null) } val lifecycleOwner = LocalLifecycleOwner.current + // Tracks whether the last update already recentred for this follow session, so re-enabling + // follow animates once instead of fighting the rider's own panning on every frame. + val wasFollowing = remember { mutableStateOf(false) } DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> @@ -89,106 +114,236 @@ fun V2xLiveMapView( } } - Column(modifier = modifier.fillMaxSize()) { - Text( - text = stringResource(R.string.v2x_map_remote_count, remotes.size), - style = MaterialTheme.typography.labelMedium, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(4.dp)) - - AndroidView( - factory = { ctx -> - initOsmForV2xMap(ctx) - MapView(ctx).apply { - setTileSource(TileSourceFactory.MAPNIK) - setMultiTouchControls(true) - controller.setZoom(17.0) - controller.setCenter(ownGeoPoint) - mapViewRef.value = this + AndroidView( + factory = { ctx -> + initOsmForV2xMap(ctx) + MapView(ctx).apply { + setTileSource(TileSourceFactory.MAPNIK) + setMultiTouchControls(true) + // Raster tiles are authored for ~160 dpi; without this they are upscaled by the + // display density and labels come out soft on a modern phone. + isTilesScaledToDpi = true + // The floating +/- buttons sit exactly where the rider's thumb lands and + // duplicate pinch-zoom. Pinch and double-tap still work. + zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) + setMinZoomLevel(4.0) + setMaxZoomLevel(20.0) + controller.setZoom(17.0) + controller.setCenter(ownGeoPoint) + // Any touch means the rider is driving the map; follow-own hands over to them + // until they ask for it back. false: the MapView's own gesture handling still + // runs, this only observes. + setOnTouchListener { _, event -> + if (event.actionMasked == MotionEvent.ACTION_DOWN) onUserPanned() + false } - }, - update = { mv -> - mv.overlays.clear() + mapViewRef.value = this + } + }, + update = { mv -> + val now = System.currentTimeMillis() - // Own position: a centred "you are here" dot, not a pin. Own position is a fact - // about the viewer rather than one of the tracked objects, and when both used - // osmdroid's identical default pin the two were indistinguishable at a glance. - mv.overlays.add( - Marker(mv).apply { - position = ownGeoPoint - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) - icon = ContextCompat.getDrawable(context, R.drawable.ic_map_own) - title = context.getString(R.string.v2x_map_own_label) - } - ) + // Intersections we can actually place: SPATEM carries signal state but no geometry + // (that is MAPEM's job), so the only position available is the sending RSU's own CAM. + val locatedSpats = spats.mapNotNull { spat -> + remotes[spat.stationId]?.let { rsu -> spat to rsu } + } + // An RSU drawn as a traffic light must not also be drawn as a CAM pin underneath it: + // two markers on one point, the lower one unreachable. + val spatStationIds = locatedSpats.map { (spat, _) -> spat.stationId }.toSet() - remotes.forEach { (stationId, cam) -> - val level = alertByStation[stationId] - val label = when (level) { - AlertLevel.WARNING -> context.getString(R.string.v2x_map_remote_warning, stationId) - AlertLevel.AWARENESS -> context.getString(R.string.v2x_map_remote_awareness, stationId) - AlertLevel.INFO -> context.getString(R.string.v2x_map_remote_info, stationId) - null -> context.getString(R.string.v2x_map_remote_plain, stationId) - } - // Teardrop pin anchored at its tip, tinted by severity. Now that these are - // custom drawables, per-instance tinting is possible - severity no longer - // depends on tapping the marker to read its label. mutate() is essential: - // without it every marker shares one ConstantState and the last tint applied - // would recolour all of them. - val pin = ContextCompat.getDrawable(context, R.drawable.ic_map_remote_station) - ?.mutate() - ?.apply { setTint(level.toMarkerColor()) } - mv.overlays.add( - Marker(mv).apply { - position = GeoPoint(cam.latitude, cam.longitude) - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) - icon = pin - title = label - } + val live = mutableSetOf() + + // Own position: a centred "you are here" dot, not a pin. Own position is a fact about + // the viewer rather than one of the tracked objects, and when both used osmdroid's + // identical default pin the two were indistinguishable at a glance. + markers.marker(mv, KEY_OWN, live).apply { + position = ownGeoPoint + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) + icon = icons.own + title = context.getString(R.string.v2x_map_own_label) + } + + remotes.forEach { (stationId, cam) -> + if (stationId in spatStationIds) return@forEach + val level = alertByStation[stationId] + val label = when (level) { + AlertLevel.WARNING -> context.getString(R.string.v2x_map_remote_warning, stationId) + AlertLevel.AWARENESS -> context.getString(R.string.v2x_map_remote_awareness, stationId) + AlertLevel.INFO -> context.getString(R.string.v2x_map_remote_info, stationId) + null -> context.getString(R.string.v2x_map_remote_plain, stationId) + } + // Teardrop pin anchored at its tip, tinted by severity, so severity no longer + // depends on tapping the marker to read its label. + markers.marker(mv, "$KEY_CAM$stationId", live).apply { + position = GeoPoint(cam.latitude, cam.longitude) + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + icon = icons.remotePin(level) + title = label + } + } + + // Hazards and signals are added after the vehicle pins, so they draw on top: a hazard + // hidden behind a CAM pin defeats the point of showing it. + denms.forEach { denm -> + markers.marker(mv, "$KEY_DENM${denm.dedupKey}", live).apply { + position = GeoPoint(denm.latitude, denm.longitude) + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + icon = icons.denm + title = denm.causeCode?.let { + context.getString( + R.string.v2x_map_denm_labeled, + it, + denm.subCauseCode ?: 0, + denm.stationId, + ) + } ?: context.getString(R.string.v2x_map_denm_plain, denm.stationId) + } + } + + locatedSpats.forEach { (spat, rsu) -> + val phase = spat.leadingPhase(now) + markers.marker(mv, "$KEY_SPAT${spat.key}", live).apply { + position = GeoPoint(rsu.latitude, rsu.longitude) + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + icon = icons.signal(phase) + title = context.getString( + R.string.v2x_spat_rx_title, spat.state.key, spat.stationId, ) + snippet = spat.state.movements.joinToString(" · ") { movement -> + val seconds = movement.current?.secondsUntil(now)?.takeIf { it in 0.0..99.0 } + context.getString(R.string.v2x_spat_group, movement.signalGroup) + + (seconds?.let { " " + context.getString(R.string.v2x_spat_countdown, it) } ?: "") + } } + } - // DENM hazard pins, added last so they draw on top of vehicle markers - a hazard - // hidden behind a CAM pin defeats the point of showing it. - denms.forEach { denm -> - mv.overlays.add( - Marker(mv).apply { - position = GeoPoint(denm.latitude, denm.longitude) - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) - icon = ContextCompat.getDrawable(context, R.drawable.ic_denm_warning) - title = denm.causeCode?.let { - context.getString( - R.string.v2x_map_denm_labeled, - it, - denm.subCauseCode ?: 0, - denm.stationId, - ) - } ?: context.getString(R.string.v2x_map_denm_plain, denm.stationId) - } - ) - } + // Drop markers for stations, hazards and intersections that have expired, then rebuild + // the overlay list in draw order from the cached Markers. Reordering moves references; + // nothing here allocates a Marker or decodes a Drawable. + markers.keys.retainAll { key -> + (key in live).also { kept -> if (!kept) markers[key]?.closeInfoWindow() } + } + mv.overlays.clear() + markers.entries + .sortedBy { (key, _) -> key.drawOrder() } + .forEach { (_, marker) -> mv.overlays.add(marker) } - mv.controller.animateTo(ownGeoPoint) - mv.invalidate() - }, - // osmdroid's onDetach() permanently tears the MapView down: afterwards its - // MapViewRepository holds a null MapView, so constructing a Marker against it throws - // NullPointerException from deep inside InfoWindow's constructor. - // - // This used to run in the DisposableEffect's onDispose, which is NOT safe: that effect - // is keyed on the lifecycle owner and disposes independently of this AndroidView, so - // the update block above could still run against an already-detached MapView and - // rebuild its markers. It crashed the app on 2026-08-17 once DENMs started arriving, - // because every incoming message recomposes this view and there are far more updates - // to land in that window than there used to be. - // - // onRelease is the callback that actually means "this View is gone": Compose - // guarantees no further update after it. - onRelease = { it.onDetach() }, - modifier = Modifier.fillMaxSize(), - ) + // setCenter, not animateTo: an animation restarted on every CAM never finishes, which + // is exactly the judder this used to show under live traffic. The one animated move is + // the rider re-enabling follow, where the travel is worth seeing. + if (followOwn) { + if (wasFollowing.value) mv.controller.setCenter(ownGeoPoint) + else mv.controller.animateTo(ownGeoPoint) + } + wasFollowing.value = followOwn + + mv.invalidate() + }, + // osmdroid's onDetach() permanently tears the MapView down: afterwards its + // MapViewRepository holds a null MapView, so constructing a Marker against it throws + // NullPointerException from deep inside InfoWindow's constructor. + // + // This used to run in the DisposableEffect's onDispose, which is NOT safe: that effect + // is keyed on the lifecycle owner and disposes independently of this AndroidView, so + // the update block above could still run against an already-detached MapView and + // rebuild its markers. It crashed the app on 2026-08-17 once DENMs started arriving, + // because every incoming message recomposes this view and there are far more updates + // to land in that window than there used to be. + // + // onRelease is the callback that actually means "this View is gone": Compose + // guarantees no further update after it. + onRelease = { + markers.clear() + it.onDetach() + }, + modifier = modifier.fillMaxSize(), + ) +} + +// ── Marker cache ────────────────────────────────────────────────────────────── + +private const val KEY_OWN = "own" +private const val KEY_CAM = "cam:" +private const val KEY_DENM = "denm:" +private const val KEY_SPAT = "spat:" + +/** Draw order: own dot at the bottom, then vehicles, with hazards and signals on top. */ +private fun String.drawOrder(): Int = when { + this == KEY_OWN -> 0 + startsWith(KEY_CAM) -> 1 + startsWith(KEY_DENM) -> 2 + else -> 3 +} + +/** + * The cached [Marker] for [key], created against [mv] on first use, recording the key in [live] + * so the caller can drop whatever it did not ask for this update. + */ +private fun MutableMap.marker( + mv: MapView, + key: String, + live: MutableSet, +): Marker { + live += key + return getOrPut(key) { Marker(mv) } +} + +/** + * Marker artwork, loaded once per composition rather than per update. + * + * The remote pin is drawn white and tinted per severity here; [mutate] is what keeps the four + * tinted copies independent, since without it they would share one ConstantState and the last + * tint applied would recolour all of them. + */ +private class MapIcons(context: Context) { + val own: Drawable? = ContextCompat.getDrawable(context, R.drawable.ic_map_own) + val denm: Drawable? = ContextCompat.getDrawable(context, R.drawable.ic_denm_warning) + + private val pins: Map = + (listOf(null) + AlertLevel.entries).associateWith { level -> + ContextCompat.getDrawable(context, R.drawable.ic_map_remote_station) + ?.mutate() + ?.apply { setTint(level.toMarkerColor()) } + } + + private val signals: Map = SignalLamp.entries.associateWith { lamp -> + ContextCompat.getDrawable(context, lamp.drawableRes) + } + + fun remotePin(level: AlertLevel?): Drawable? = pins[level] + fun signal(lamp: SignalLamp): Drawable? = signals[lamp] +} + +// ── Signal phase → lamp ─────────────────────────────────────────────────────── + +/** Which lamp of the traffic-light marker is lit. */ +private enum class SignalLamp(val drawableRes: Int) { + RED(R.drawable.ic_map_spat_red), + AMBER(R.drawable.ic_map_spat_amber), + GREEN(R.drawable.ic_map_spat_green), + DARK(R.drawable.ic_map_spat_dark), +} + +/** + * The lamp to light for this intersection. + * + * Without MAPEM there is no lane geometry, so there is no way to know which of an intersection's + * signal groups applies to the rider's own approach. This follows the rule the Dashboard's + * SignalCard already uses — the group changing soonest speaks for the intersection — so the same + * intersection reads the same way in both places rather than inventing a second convention. + */ +private fun SpatIntersection.leadingPhase(nowMs: Long): SignalLamp { + val leading = state.movements.minByOrNull { movement -> + movement.current?.secondsUntil(nowMs)?.takeIf { it >= 0.0 } ?: Double.MAX_VALUE + } + val phase = leading?.current?.phase + return when { + phase == null -> SignalLamp.DARK + phase.isGo -> SignalLamp.GREEN + phase.isStop -> SignalLamp.RED + phase.isTransition -> SignalLamp.AMBER + else -> SignalLamp.DARK // UNAVAILABLE / DARK / caution } } @@ -228,5 +383,10 @@ private fun initOsmForV2xMap(context: Context) { Configuration.getInstance().apply { load(context, context.getSharedPreferences("osmdroid", Context.MODE_PRIVATE)) userAgentValue = context.packageName + // Panning off the edge of the cache is what makes a raster map feel slow: the default + // 600 MB cap is plenty, but the default 2 download threads are not when a pan exposes a + // screenful of new tiles at once. + tileDownloadThreads = 6.toShort() + tileFileSystemThreads = 6.toShort() } } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/V2xMapScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/V2xMapScreen.kt new file mode 100644 index 0000000..7537778 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/V2xMapScreen.kt @@ -0,0 +1,203 @@ +package com.hawhamburg.micr0bu.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.MyLocation +import androidx.compose.material.icons.filled.Place +import androidx.compose.material.icons.filled.Traffic +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.hawhamburg.micr0bu.R +import com.hawhamburg.micr0bu.viewmodel.MqttViewModel + +/** + * Full-screen V2X live map — the map and nothing else, reached from the map button on the V2X + * Monitor screen. + * + * Identical on both hardware paths. Everything drawn here comes from + * [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository], which already merges the CiT One's MQTT + * feed and the ESP32-C5's serial feed into one set of flows, so this screen never has to know + * which OBU is connected. + * + * The chrome is deliberately minimal and floats over the map rather than boxing it in: a back + * button, a live count per message type, and a recentre button. The counts double as the map's + * legend — each one carries the same icon family as the marker it counts. + */ +@Composable +fun V2xMapScreen( + viewModel: MqttViewModel, + onBack: () -> Unit, +) { + val ownCamPosition by viewModel.ownCamPosition.collectAsState() + // Road users from the detection engine PLUS roadside units, which it deliberately does not + // track - an RSU is what carries the traffic lights below. + val stations by viewModel.stationsInRange.collectAsState() + val alerts by viewModel.useCaseAlerts.collectAsState() + val denms by viewModel.denmEvents.collectAsState() + val spats by viewModel.spatIntersections.collectAsState() + + // Follow is on until the rider touches the map, and comes back when they ask for it. Without + // the hand-over, every incoming CAM would drag the viewport back to the ego position and the + // map could not be panned at all while traffic is flowing. + var followOwn by remember { mutableStateOf(true) } + + // SPATEM carries no geometry of its own, so an intersection can only be placed if its RSU has + // also been heard over CAM. Saying so is better than silently dropping it: "the map shows two + // of the three lights I can see in the list" is otherwise an unexplained discrepancy. + val unlocatedSpats = spats.count { it.stationId !in stations.keys } + + Box(modifier = Modifier.fillMaxSize()) { + V2xLiveMapView( + own = ownCamPosition, + remotes = stations, + alerts = alerts, + denms = denms, + spats = spats, + followOwn = followOwn, + onUserPanned = { followOwn = false }, + modifier = Modifier.fillMaxSize(), + ) + + // ── Floating header: back + live counts, which double as the legend ── + Row( + modifier = Modifier + .align(Alignment.TopStart) + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MapChrome { + IconButton(onClick = onBack, modifier = Modifier.size(36.dp)) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.v2x_map_back), + ) + } + } + + Spacer(Modifier.width(8.dp)) + + MapChrome { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MapCount( + icon = Icons.Default.Place, + tint = CamPinBlue, + count = stations.size, + label = stringResource(R.string.v2x_map_legend_cam), + ) + MapCount( + icon = Icons.Default.Warning, + tint = HazardAmber, + count = denms.size, + label = stringResource(R.string.v2x_map_legend_denm), + ) + MapCount( + icon = Icons.Default.Traffic, + tint = SignalGreenDot, + count = spats.size, + label = stringResource(R.string.v2x_map_legend_spat), + ) + } + } + } + + if (unlocatedSpats > 0) { + MapChrome( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(12.dp), + ) { + Text( + text = stringResource(R.string.v2x_map_spat_unlocated, unlocatedSpats), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + ) + } + } + + // Recentre: lit while following, so the button also reports which mode the map is in. + FloatingActionButton( + onClick = { followOwn = true }, + containerColor = if (followOwn) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + ) { + Icon( + Icons.Default.MyLocation, + contentDescription = stringResource(R.string.v2x_map_follow), + tint = if (followOwn) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** A translucent pill for anything floating over the map, so chrome stays readable over tiles. */ +@Composable +private fun MapChrome( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f), + tonalElevation = 3.dp, + shadowElevation = 2.dp, + modifier = modifier, + ) { content() } +} + +@Composable +private fun MapCount(icon: ImageVector, tint: Color, count: Int, label: String) { + Row( + horizontalArrangement = Arrangement.spacedBy(3.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = label, tint = tint, modifier = Modifier.size(16.dp)) + Text( + text = count.toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +// Legend tints, matching the marker artwork rather than the theme: these name the drawables on +// the map, so they must not shift with light/dark mode the way theme colours do. +private val CamPinBlue = Color(0xFF78909C) +private val HazardAmber = Color(0xFFFFC107) +private val SignalGreenDot = Color(0xFF4CAF50) diff --git a/app/src/main/res/drawable/ic_map_spat_amber.xml b/app/src/main/res/drawable/ic_map_spat_amber.xml new file mode 100644 index 0000000..d89ba21 --- /dev/null +++ b/app/src/main/res/drawable/ic_map_spat_amber.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_map_spat_dark.xml b/app/src/main/res/drawable/ic_map_spat_dark.xml new file mode 100644 index 0000000..657f627 --- /dev/null +++ b/app/src/main/res/drawable/ic_map_spat_dark.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_map_spat_green.xml b/app/src/main/res/drawable/ic_map_spat_green.xml new file mode 100644 index 0000000..81ca1a2 --- /dev/null +++ b/app/src/main/res/drawable/ic_map_spat_green.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_map_spat_red.xml b/app/src/main/res/drawable/ic_map_spat_red.xml new file mode 100644 index 0000000..a33b3a3 --- /dev/null +++ b/app/src/main/res/drawable/ic_map_spat_red.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 687fcbb..4833bca 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -142,12 +142,18 @@ Noch kein GPS-Signal - gehen Sie ins Freie Standortkarte Aktueller Standort - %1$d erfasste externe Verkehrsteilnehmer Eigen (Ego) Extern #%1$d Extern #%1$d · Info Extern #%1$d · Aufmerksamkeit Extern #%1$d · Warnung + V2X-Live-Karte + Zurück + Auf eigene Position zentrieren + Verkehrsteilnehmer (CAM) + Gefahren (DENM) + Signale (SPATEM) + %1$d Signal(e) nicht dargestellt - Senderposition unbekannt Einstellungen @@ -197,7 +203,6 @@ Noch keine Nachrichten Liste Topics - Karte Mit der OBU verbinden und auf V2X-Verkehr warten Noch keine Nachrichten zu diesem Thema diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2efa2d6..5d5cd9b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -143,12 +143,18 @@ No GPS fix yet - move to an open area Location Map Current Location - %1$d tracked remote road user(s) Own (ego) Remote #%1$d Remote #%1$d · Info Remote #%1$d · Awareness Remote #%1$d · Warning + V2X Live Map + Back + Centre on own position + Road users (CAM) + Hazards (DENM) + Signals (SPATEM) + %1$d signal(s) not shown - sender position unknown Settings @@ -198,7 +204,6 @@ No messages yet List Topics - Map Connect to the OBU and wait for V2X traffic No messages on this topic yet