Give the V2X live map its own screen, and a traffic light per SPATEM

The map was a third view mode inside the V2X Monitor's topic pane, below
the use case alert panel and the DENM/CAM TX cards. On a phone that left
it about a third of the display tall, which is not enough to see where
anything is relative to anything else - the one thing a map is for. It
is now its own destination, V2xMapScreen on route v2x_map, reached from
a map button in that screen's header. The button sits in the header
rather than the view-mode row so it is also reachable from the message
detail pane and does not move as the available modes change with the
selected hardware. The status bar and bottom nav are hidden on this
route; the screen carries its own floating back button, and system back
still works. Both hardware paths get the same screen: everything drawn
comes from CamUseCaseRepository, which already merges the CiT One's MQTT
feed and the ESP32-C5's serial feed into one set of flows.

With the map gone from the toggle row, the row offers a single choice on
the ESP32-C5 path - there is no broker there and `topics` is always
empty - so it is hidden entirely in that mode.

SPATEM markers. Hazards already drew as a warning triangle; signalised
intersections did not draw at all. They now draw as a traffic light with
one lamp lit. Two things are worth knowing, because neither is forced by
the data:

- SPATEM carries signal state but no geometry, which is MAPEM's job and
  MAPEM is not decoded. The only position available is the sending RSU's
  own CAM, so the light is drawn there, and that RSU is drawn once - as
  the light, not as a CAM pin with a light on top of it. An intersection
  whose sender has not been heard over CAM cannot be placed; the map
  says how many rather than dropping them silently.
- Which lamp lights follows the rule DashboardScreen's SignalCard
  already uses, the signal group changing soonest speaking for the
  intersection, so the same intersection reads the same way in both
  places instead of inventing a second convention.

Four drawables rather than one tinted at runtime: setTint recolours
every path in a vector, so a single shared asset would turn the whole
light one flat colour and stop it reading as a traffic light.

Marker reuse. Every incoming message recomposes the map, and the update
block cleared the overlay list and rebuilt every Marker, decoding and
mutating a fresh Drawable per marker - at up to 10 Hz per station. It
also called animateTo(own) on every update, restarting the pan animation
before it could finish. Drawables are now loaded once per alert level
and phase and shared (osmdroid sets the icon's bounds on each draw, so
one instance across markers is safe), Markers are cached by key, and the
overlay list is only reordered, which moves references without
allocating. Following uses setCenter, keeping animateTo for the one move
worth seeing: the rider asking for follow back.

Follow-own now hands over to the rider on the first touch and returns
via the location button, which lights up while following. Before this
the map could not be panned at all while traffic was flowing, since the
next CAM dragged the viewport back.

Also on the map view: tiles scaled to DPI, the floating +/- buttons off
(they sit where the thumb lands and duplicate pinch), a zoom range, and
more tile threads so a pan that exposes a screenful of new tiles is not
served two at a time.

Compiles and the unit tests pass. None of it has been seen with live
traffic; TODO.md lists the on-device checks under "Waiting on hardware",
including which of the HAW RSUs send CAM alongside SPATEM.
This commit is contained in:
Ashin Walpola
2026-09-15 17:23:01 +02:00
parent 0f06cdb339
commit 01204a2c22
12 changed files with 726 additions and 146 deletions
+23
View File
@@ -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
@@ -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(
@@ -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
}
}
@@ -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,
@@ -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<Long, Cam>,
alerts: List<UseCaseAlert>,
denms: List<DenmEvent> = emptyList(),
spats: List<SpatIntersection> = 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<String, Marker>() }
val mapViewRef = remember { mutableStateOf<MapView?>(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<String>()
// 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<String, Marker>.marker(
mv: MapView,
key: String,
live: MutableSet<String>,
): 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<AlertLevel?, Drawable?> =
(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, Drawable?> = 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()
}
}
@@ -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)
@@ -0,0 +1,38 @@
<!--
Live-map marker for a signalised intersection heard over SPATEM: a traffic light housing with
the lamp for the intersection's leading phase lit and the other two dimmed.
One drawable per lit lamp rather than one drawable tinted at runtime: setTint recolours every
path in a vector, so a single shared asset could not keep the unlit lamps dark while colouring
the lit one - the whole light would turn one flat colour and stop reading as a traffic light.
Anchored at the bottom in V2xLiveMapView, so the housing sits above the intersection rather
than covering it.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="30dp"
android:height="30dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- White outline first, so the marker stays legible over dark map features. -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M4.4,0.8H19.6V23.2H4.4z" />
<!-- Housing. -->
<path
android:fillColor="#FF263238"
android:pathData="M5.8,2.0H18.2V22.0H5.8z" />
<!-- Lamps, top to bottom: red, amber, green. -->
<path
android:fillColor="#FF5A2220"
android:pathData="M12,6.6m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
<path
android:fillColor="#FFFFC107"
android:pathData="M12,12.0m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
<path
android:fillColor="#FF1E4D2B"
android:pathData="M12,17.4m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
</vector>
@@ -0,0 +1,38 @@
<!--
Live-map marker for a signalised intersection heard over SPATEM: a traffic light housing with
the lamp for the intersection's leading phase lit and the other two dimmed.
One drawable per lit lamp rather than one drawable tinted at runtime: setTint recolours every
path in a vector, so a single shared asset could not keep the unlit lamps dark while colouring
the lit one - the whole light would turn one flat colour and stop reading as a traffic light.
Anchored at the bottom in V2xLiveMapView, so the housing sits above the intersection rather
than covering it.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="30dp"
android:height="30dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- White outline first, so the marker stays legible over dark map features. -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M4.4,0.8H19.6V23.2H4.4z" />
<!-- Housing. -->
<path
android:fillColor="#FF263238"
android:pathData="M5.8,2.0H18.2V22.0H5.8z" />
<!-- Lamps, top to bottom: red, amber, green. -->
<path
android:fillColor="#FF5A2220"
android:pathData="M12,6.6m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
<path
android:fillColor="#FF4A2E1C"
android:pathData="M12,12.0m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
<path
android:fillColor="#FF1E4D2B"
android:pathData="M12,17.4m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
</vector>
@@ -0,0 +1,38 @@
<!--
Live-map marker for a signalised intersection heard over SPATEM: a traffic light housing with
the lamp for the intersection's leading phase lit and the other two dimmed.
One drawable per lit lamp rather than one drawable tinted at runtime: setTint recolours every
path in a vector, so a single shared asset could not keep the unlit lamps dark while colouring
the lit one - the whole light would turn one flat colour and stop reading as a traffic light.
Anchored at the bottom in V2xLiveMapView, so the housing sits above the intersection rather
than covering it.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="30dp"
android:height="30dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- White outline first, so the marker stays legible over dark map features. -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M4.4,0.8H19.6V23.2H4.4z" />
<!-- Housing. -->
<path
android:fillColor="#FF263238"
android:pathData="M5.8,2.0H18.2V22.0H5.8z" />
<!-- Lamps, top to bottom: red, amber, green. -->
<path
android:fillColor="#FF5A2220"
android:pathData="M12,6.6m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
<path
android:fillColor="#FF4A2E1C"
android:pathData="M12,12.0m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
<path
android:fillColor="#FF4CAF50"
android:pathData="M12,17.4m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
</vector>
@@ -0,0 +1,38 @@
<!--
Live-map marker for a signalised intersection heard over SPATEM: a traffic light housing with
the lamp for the intersection's leading phase lit and the other two dimmed.
One drawable per lit lamp rather than one drawable tinted at runtime: setTint recolours every
path in a vector, so a single shared asset could not keep the unlit lamps dark while colouring
the lit one - the whole light would turn one flat colour and stop reading as a traffic light.
Anchored at the bottom in V2xLiveMapView, so the housing sits above the intersection rather
than covering it.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="30dp"
android:height="30dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- White outline first, so the marker stays legible over dark map features. -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M4.4,0.8H19.6V23.2H4.4z" />
<!-- Housing. -->
<path
android:fillColor="#FF263238"
android:pathData="M5.8,2.0H18.2V22.0H5.8z" />
<!-- Lamps, top to bottom: red, amber, green. -->
<path
android:fillColor="#FFFF5252"
android:pathData="M12,6.6m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
<path
android:fillColor="#FF4A2E1C"
android:pathData="M12,12.0m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
<path
android:fillColor="#FF1E4D2B"
android:pathData="M12,17.4m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
</vector>
+7 -2
View File
@@ -142,12 +142,18 @@
<string name="gnss_no_fix">Noch kein GPS-Signal - gehen Sie ins Freie</string>
<string name="map_title">Standortkarte</string>
<string name="map_location_label">Aktueller Standort</string>
<string name="v2x_map_remote_count">%1$d erfasste externe Verkehrsteilnehmer</string>
<string name="v2x_map_own_label">Eigen (Ego)</string>
<string name="v2x_map_remote_plain">Extern #%1$d</string>
<string name="v2x_map_remote_info">Extern #%1$d · Info</string>
<string name="v2x_map_remote_awareness">Extern #%1$d · Aufmerksamkeit</string>
<string name="v2x_map_remote_warning">Extern #%1$d · Warnung</string>
<string name="v2x_map_title">V2X-Live-Karte</string>
<string name="v2x_map_back">Zurück</string>
<string name="v2x_map_follow">Auf eigene Position zentrieren</string>
<string name="v2x_map_legend_cam">Verkehrsteilnehmer (CAM)</string>
<string name="v2x_map_legend_denm">Gefahren (DENM)</string>
<string name="v2x_map_legend_spat">Signale (SPATEM)</string>
<string name="v2x_map_spat_unlocated">%1$d Signal(e) nicht dargestellt - Senderposition unbekannt</string>
<!-- Settings -->
<string name="settings_title">Einstellungen</string>
@@ -197,7 +203,6 @@
<string name="mqtt_no_topics">Noch keine Nachrichten</string>
<string name="mqtt_view_list">Liste</string>
<string name="mqtt_view_topics">Topics</string>
<string name="mqtt_view_map">Karte</string>
<string name="mqtt_no_topics_hint">Mit der OBU verbinden und auf V2X-Verkehr warten</string>
<string name="mqtt_no_messages">Noch keine Nachrichten zu diesem Thema</string>
+7 -2
View File
@@ -143,12 +143,18 @@
<string name="gnss_no_fix">No GPS fix yet - move to an open area</string>
<string name="map_title">Location Map</string>
<string name="map_location_label">Current Location</string>
<string name="v2x_map_remote_count">%1$d tracked remote road user(s)</string>
<string name="v2x_map_own_label">Own (ego)</string>
<string name="v2x_map_remote_plain">Remote #%1$d</string>
<string name="v2x_map_remote_info">Remote #%1$d · Info</string>
<string name="v2x_map_remote_awareness">Remote #%1$d · Awareness</string>
<string name="v2x_map_remote_warning">Remote #%1$d · Warning</string>
<string name="v2x_map_title">V2X Live Map</string>
<string name="v2x_map_back">Back</string>
<string name="v2x_map_follow">Centre on own position</string>
<string name="v2x_map_legend_cam">Road users (CAM)</string>
<string name="v2x_map_legend_denm">Hazards (DENM)</string>
<string name="v2x_map_legend_spat">Signals (SPATEM)</string>
<string name="v2x_map_spat_unlocated">%1$d signal(s) not shown - sender position unknown</string>
<!-- Settings -->
<string name="settings_title">Settings</string>
@@ -198,7 +204,6 @@
<string name="mqtt_no_topics">No messages yet</string>
<string name="mqtt_view_list">List</string>
<string name="mqtt_view_topics">Topics</string>
<string name="mqtt_view_map">Map</string>
<string name="mqtt_no_topics_hint">Connect to the OBU and wait for V2X traffic</string>
<string name="mqtt_no_messages">No messages on this topic yet</string>