feat: CAM-based use case detection engine, decouple DENM from sensor triggers

Added:
- UseCaseDetectionEngine: IMA-B, IMA-S, RTW-B, LTW-B, SMVA/BCW-B via per-station
  CAM history, yaw-rate/heading-trend turn detection, Info/Awareness/Warning alerts
- Ego state from obu_gnss (~4Hz, yaw rate) with phone GPS fallback when stale
- Human-readable alert narratives + technical detail in V2X Monitor
- Settings > Use Case Alerts per-use-case toggles

Fixed:
- DENM no longer auto-fires on braking/stopping; manual-only (antenna/RSU test)
- Removed unwired CamBuilder.kt (dead code, contradicted CAM-only architecture)
- TripRecordingService no longer depends on MQTT/OBU connectivity
This commit is contained in:
Ashin Walpola
2026-07-02 15:04:34 +02:00
parent 4b20820ded
commit 7b8a8cbb32
19 changed files with 652 additions and 194 deletions
Binary file not shown.
+6
View File
@@ -5,6 +5,8 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" /> <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<!-- Location --> <!-- Location -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
@@ -21,6 +23,10 @@
<!-- Keep CPU active while recording with screen off --> <!-- Keep CPU active while recording with screen off -->
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Allows prompting the user to exempt this app from battery optimization
so GPS callbacks are not throttled when the screen is off during a trip. -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-feature android:name="android.hardware.sensor.accelerometer" android:required="false" /> <uses-feature android:name="android.hardware.sensor.accelerometer" android:required="false" />
<uses-feature android:name="android.hardware.sensor.gyroscope" android:required="false" /> <uses-feature android:name="android.hardware.sensor.gyroscope" android:required="false" />
<uses-feature android:name="android.hardware.sensor.compass" android:required="false" /> <uses-feature android:name="android.hardware.sensor.compass" android:required="false" />
@@ -1,8 +1,11 @@
package com.hawhamburg.micr0bu package com.hawhamburg.micr0bu
import android.Manifest import android.Manifest
import android.content.Intent
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.activity.SystemBarStyle import androidx.activity.SystemBarStyle
@@ -46,6 +49,7 @@ import com.hawhamburg.micr0bu.ui.screens.MqttSettingsScreen
import com.hawhamburg.micr0bu.ui.screens.SensorsSettingsScreen import com.hawhamburg.micr0bu.ui.screens.SensorsSettingsScreen
import com.hawhamburg.micr0bu.ui.screens.SettingsScreen import com.hawhamburg.micr0bu.ui.screens.SettingsScreen
import com.hawhamburg.micr0bu.ui.screens.UnitsSettingsScreen import com.hawhamburg.micr0bu.ui.screens.UnitsSettingsScreen
import com.hawhamburg.micr0bu.ui.screens.UseCaseAlertsSettingsScreen
import com.hawhamburg.micr0bu.ui.theme.MicrOBUTheme import com.hawhamburg.micr0bu.ui.theme.MicrOBUTheme
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
import com.hawhamburg.micr0bu.viewmodel.SensorViewModel import com.hawhamburg.micr0bu.viewmodel.SensorViewModel
@@ -74,7 +78,10 @@ class MainActivity : AppCompatActivity() {
val activeTransport by mqttViewModel.activeTransport.collectAsState() val activeTransport by mqttViewModel.activeTransport.collectAsState()
val usbConnected by mqttViewModel.usbConnected.collectAsState() val usbConnected by mqttViewModel.usbConnected.collectAsState()
val obuStationTypeWarning by mqttViewModel.obuStationTypeWarning.collectAsState() val obuStationTypeWarning by mqttViewModel.obuStationTypeWarning.collectAsState()
val obuStationType by mqttViewModel.obuStationType.collectAsState()
val tripServiceState by tripViewModel.serviceState.collectAsState() val tripServiceState by tripViewModel.serviceState.collectAsState()
val showBatteryOptPrompt by tripViewModel.showBatteryOptPrompt.collectAsState()
val useCaseEnabledMap by mqttViewModel.useCaseEnabledMap.collectAsState()
MicrOBUTheme(darkTheme = state.darkTheme) { MicrOBUTheme(darkTheme = state.darkTheme) {
val view = LocalView.current val view = LocalView.current
@@ -128,6 +135,7 @@ class MainActivity : AppCompatActivity() {
activeTransport = activeTransport, activeTransport = activeTransport,
usbCableConnected = usbConnected, usbCableConnected = usbConnected,
obuStationTypeWarning = obuStationTypeWarning, obuStationTypeWarning = obuStationTypeWarning,
obuStationType = obuStationType,
onNavigateToConnection = { navController.navigate(Screen.Connection.route) }, onNavigateToConnection = { navController.navigate(Screen.Connection.route) },
onNavigateToSensors = { onNavigateToSensors = {
navController.navigate(Screen.Sensors.route) { navController.navigate(Screen.Sensors.route) {
@@ -147,6 +155,7 @@ class MainActivity : AppCompatActivity() {
state = state, state = state,
mqttConnectionState = mqttConnectionState, mqttConnectionState = mqttConnectionState,
tripServiceState = tripServiceState, tripServiceState = tripServiceState,
showBatteryOptPrompt = showBatteryOptPrompt,
onToggleRecording = { onToggleRecording = {
// Start / stop both CSV recording and the event detection service // Start / stop both CSV recording and the event detection service
viewModel.toggleRecording() viewModel.toggleRecording()
@@ -155,6 +164,15 @@ class MainActivity : AppCompatActivity() {
onOpenSessionLog = { onOpenSessionLog = {
navController.navigate(Screen.Log.route) navController.navigate(Screen.Log.route)
}, },
onOpenBatterySettings = {
startActivity(
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:$packageName")
}
)
tripViewModel.recheckBatteryOpt()
},
onDismissBatteryOptPrompt = tripViewModel::dismissBatteryOptPrompt,
) )
} }
composable(Screen.Log.route) { composable(Screen.Log.route) {
@@ -205,10 +223,18 @@ class MainActivity : AppCompatActivity() {
onOpenUnits = { navController.navigate(Screen.SettingsUnits.route) }, onOpenUnits = { navController.navigate(Screen.SettingsUnits.route) },
onOpenConnection = { navController.navigate(Screen.SettingsConnection.route) }, onOpenConnection = { navController.navigate(Screen.SettingsConnection.route) },
onOpenMqtt = { navController.navigate(Screen.SettingsMqtt.route) }, onOpenMqtt = { navController.navigate(Screen.SettingsMqtt.route) },
onOpenUseCaseAlerts = { navController.navigate(Screen.SettingsUseCaseAlerts.route) },
onOpenDeveloper = { navController.navigate(Screen.SettingsDeveloper.route) }, onOpenDeveloper = { navController.navigate(Screen.SettingsDeveloper.route) },
onOpenAbout = { navController.navigate(Screen.SettingsAbout.route) }, onOpenAbout = { navController.navigate(Screen.SettingsAbout.route) },
) )
} }
composable(Screen.SettingsUseCaseAlerts.route) {
UseCaseAlertsSettingsScreen(
enabledMap = useCaseEnabledMap,
onToggle = mqttViewModel::setUseCaseEnabled,
onBack = { navController.popBackStack() },
)
}
composable(Screen.SettingsConnection.route) { composable(Screen.SettingsConnection.route) {
ConnectionSettingsScreen( ConnectionSettingsScreen(
mqttPrefs = mqttPrefs, mqttPrefs = mqttPrefs,
@@ -2,8 +2,11 @@ package com.hawhamburg.micr0bu.data
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import com.hawhamburg.micr0bu.viewmodel.RecordingSession import com.hawhamburg.micr0bu.viewmodel.RecordingSession
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
@@ -17,28 +20,34 @@ fun sessionFileName(session: RecordingSession): String =
.replace(" ", "_").replace(":", "-")}.csv" .replace(" ", "_").replace(":", "-")}.csv"
/** /**
* Returns the full CSV content for a session. * Shares the session CSV via Android's share sheet.
* If stream-to-file data exists (v0.2.0+ recording) the real per-sample rows are returned. *
* Falls back to a metadata-only summary for older sessions. * Streams the on-disk data file directly to the cache without loading it into memory —
* avoids OOM for long sessions (1 hr+ at 50 Hz can exceed 100 MB as a String).
* Must be called from a coroutine; file I/O runs on [Dispatchers.IO].
*/ */
fun readSessionCsvContent(context: Context, session: RecordingSession): String { suspend fun shareSessionCsv(context: Context, session: RecordingSession) {
val dataFile = File(File(context.filesDir, "sessions"), "${session.id}.csv")
return if (dataFile.exists()) dataFile.readText() else buildSessionCsv(session)
}
fun shareSessionCsv(context: Context, session: RecordingSession) {
val fileName = sessionFileName(session) val fileName = sessionFileName(session)
val content = readSessionCsvContent(context, session) val dataFile = File(File(context.filesDir, "sessions"), "${session.id}.csv")
val cacheFile = File(context.cacheDir, fileName)
// Write to cache so FileProvider can serve it via ACTION_SEND withContext(Dispatchers.IO) {
val file = File(context.cacheDir, fileName).also { it.writeText(content) } if (dataFile.exists()) {
dataFile.inputStream().buffered().use { input ->
cacheFile.outputStream().buffered().use { output ->
input.copyTo(output)
}
}
} else {
cacheFile.writeText(buildSessionCsv(session))
}
}
val uri = FileProvider.getUriForFile( val uri = FileProvider.getUriForFile(
context, context,
"${context.packageName}.fileprovider", "${context.packageName}.fileprovider",
file, cacheFile,
) )
val intent = Intent(Intent.ACTION_SEND).apply { val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/csv" type = "text/csv"
putExtra(Intent.EXTRA_STREAM, uri) putExtra(Intent.EXTRA_STREAM, uri)
@@ -48,6 +57,25 @@ fun shareSessionCsv(context: Context, session: RecordingSession) {
context.startActivity(Intent.createChooser(intent, "Export session")) context.startActivity(Intent.createChooser(intent, "Export session"))
} }
/**
* Streams the session CSV directly into a SAF [Uri] (from the "Save to device" picker).
*
* Same streaming approach as [shareSessionCsv] — no full-file String ever allocated.
* Must be called from a coroutine; file I/O runs on [Dispatchers.IO].
*/
suspend fun saveSessionCsvToUri(context: Context, session: RecordingSession, uri: Uri) {
val dataFile = File(File(context.filesDir, "sessions"), "${session.id}.csv")
withContext(Dispatchers.IO) {
context.contentResolver.openOutputStream(uri)?.buffered()?.use { output ->
if (dataFile.exists()) {
dataFile.inputStream().buffered().use { input -> input.copyTo(output) }
} else {
output.write(buildSessionCsv(session).toByteArray(Charsets.UTF_8))
}
}
}
}
/** /**
* Metadata-only CSV fallback — used for sessions recorded before stream-to-file was implemented, * Metadata-only CSV fallback — used for sessions recorded before stream-to-file was implemented,
* and by [readSessionCsvContent] when no data file is present on disk. * and by [readSessionCsvContent] when no data file is present on disk.
@@ -1,57 +0,0 @@
package com.hawhamburg.micr0bu.data.mqtt
import com.hawhamburg.micr0bu.data.GnssReading
import com.hawhamburg.micr0bu.data.ImuReading
import org.json.JSONArray
import org.json.JSONObject
import kotlin.math.PI
/**
* MQTT topic used to inject a CAM message into the OBU.
*
* TODO: Confirm this topic with consider it GmbH / HAW Hamburg before production use.
* The MQTT API v6 documentation only covers *received/processed* output topics
* (v2x-uca/output/json/…). No inbound CAM injection topic is currently documented.
* "v2x/tx/cam" is a reasonable candidate based on the naming convention; verify with
* the OBU firmware team before using in production.
*/
const val CAM_TX_TOPIC = "v2x/tx/cam"
/**
* Builds a CAM JSON payload for test transmission.
*
* The schema mirrors the processed RX format on [v2x-uca/output/json/cam] per MQTT API v6 §2.2.3,
* adapted for outbound injection with phone sensor data.
*/
object CamBuilder {
fun build(
gnss: GnssReading,
gyro: ImuReading?,
stationId: Long,
): String {
// Gyroscope Z axis (rad/s) → yaw rate (deg/s)
val yawRateDps = gyro?.z?.let { it.toDouble() * (180.0 / PI) } ?: 0.0
return JSONObject().apply {
put("type", "v2x-cam")
put("stationId", stationId)
put("stationType", "cyclist") // ETSI stationType = 2 (cyclist) mapped to string label
put("position", JSONObject().apply {
put("type", "Point")
// GeoJSON coordinate order: [longitude, latitude, altitude]
put("coordinates", JSONArray().apply {
put(gnss.longitude)
put(gnss.latitude)
put(gnss.altitude)
})
})
put("heading_deg", gnss.bearingDeg.toDouble())
put("speed_mps", gnss.speedMs.toDouble())
put("yawRate_dps", yawRateDps)
put("vehicleRole", "default")
put("pathHistory", JSONArray())
put("positionError_stdDev", gnss.accuracyM.toDouble())
}.toString()
}
}
@@ -1,9 +1,9 @@
package com.hawhamburg.micr0bu.data.mqtt package com.hawhamburg.micr0bu.data.mqtt
import android.content.Context
import com.hawhamburg.micr0bu.data.transport.TransportType import com.hawhamburg.micr0bu.data.transport.TransportType
import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector
import dagger.hilt.android.qualifiers.ApplicationContext import com.hawhamburg.micr0bu.domain.denm.DENM_CTRL_TOPIC
import com.hawhamburg.micr0bu.domain.denm.buildDenmPayload
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
@@ -34,6 +35,8 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import org.eclipse.paho.client.mqttv3.MqttMessage as PahoMqttMessage import org.eclipse.paho.client.mqttv3.MqttMessage as PahoMqttMessage
private const val MAX_MESSAGES_PER_TOPIC = 50
private val SUBSCRIBED_TOPICS = listOf( private val SUBSCRIBED_TOPICS = listOf(
"sys/state/heartbeat", "sys/state/heartbeat",
"sys/state/cellular", "sys/state/cellular",
@@ -47,7 +50,6 @@ private val SUBSCRIBED_TOPICS = listOf(
@Singleton @Singleton
class MqttRepository @Inject constructor( class MqttRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val prefs: MqttPreferences, private val prefs: MqttPreferences,
private val usbDetector: UsbNetworkDetector, private val usbDetector: UsbNetworkDetector,
) { ) {
@@ -64,6 +66,12 @@ class MqttRepository @Inject constructor(
) )
val messages: SharedFlow<MqttMessage> = _messages.asSharedFlow() val messages: SharedFlow<MqttMessage> = _messages.asSharedFlow()
// Per-topic message log, kept here (singleton) so it survives even when no screen is
// collecting — e.g. DENM TX messages emitted by TripRecordingService while the V2X
// Monitor screen isn't open.
private val _topicMessages = MutableStateFlow<Map<String, List<MqttMessage>>>(emptyMap())
val topicMessages: StateFlow<Map<String, List<MqttMessage>>> = _topicMessages.asStateFlow()
/** Active transport derived from persisted prefs. */ /** Active transport derived from persisted prefs. */
val activeTransport: StateFlow<TransportType> = prefs.prefsFlow val activeTransport: StateFlow<TransportType> = prefs.prefsFlow
.map { p -> .map { p ->
@@ -75,6 +83,115 @@ class MqttRepository @Inject constructor(
@Volatile private var activeClient: MqttAsyncClient? = null @Volatile private var activeClient: MqttAsyncClient? = null
private var connectJob: Job? = null private var connectJob: Job? = null
// ── DENM use case state ──────────────────────────────────────────────────
// Shared between MqttViewModel (manual trigger) and TripRecordingService
// (automatic event-driven triggers). Only one use case may be active at a time.
private val _denmActive = MutableStateFlow(false)
val denmActive: StateFlow<Boolean> = _denmActive.asStateFlow()
private val _lastDenmPayload = MutableStateFlow<String?>(null)
val lastDenmPayload: StateFlow<String?> = _lastDenmPayload.asStateFlow()
private val _activeDenmUseCase = MutableStateFlow<String?>(null)
/** The use case id (e.g. "c2c-eebl") currently active on the OBU, if any. */
val activeDenmUseCase: StateFlow<String?> = _activeDenmUseCase.asStateFlow()
/**
* True if the last activate/deactivate couldn't reach the OBU because the link was down.
* Cleared once [resyncDenmOnReconnect] successfully republishes on the next connection.
*/
@Volatile private var pendingDenmResync = false
init {
// Re-send the last DENM control message once the link comes back, so a use case
// triggered (or cleared) while the OBU was unreachable still takes effect.
scope.launch {
_connectionState.collect { state ->
if (state == MqttConnectionState.CONNECTED) resyncDenmOnReconnect()
}
}
}
/**
* Activate [useCaseId] on the OBU. No-op if a use case is already active
* (per spec, only one DENM use case may be active at a time).
* Local state updates regardless of connectivity — recording/event-detection never
* blocks on the OBU link; if the link is down, the activation is resent on reconnect.
* Returns true if the activation was recorded.
*/
fun activateDenm(useCaseId: String): Boolean {
if (_denmActive.value) return false
val payload = buildDenmPayload(useCase = useCaseId, active = true)
_lastDenmPayload.value = payload
_denmActive.value = true
_activeDenmUseCase.value = useCaseId
sendDenmPayload(payload)
return true
}
/**
* Deactivate whatever DENM use case is currently active. No-op if none is active.
* Same offline-safe behaviour as [activateDenm] — resent on reconnect if needed.
*/
fun deactivateDenm() {
if (!_denmActive.value) return
val useCaseId = _activeDenmUseCase.value ?: return
val payload = buildDenmPayload(useCase = useCaseId, active = false)
_lastDenmPayload.value = payload
_denmActive.value = false
_activeDenmUseCase.value = null
sendDenmPayload(payload)
}
/**
* Publishes [payload] immediately if the OBU link is up. If not, marks the state for
* resync so it's republished as soon as the connection is restored — no DENM control
* message is silently dropped.
*/
private fun sendDenmPayload(payload: String) {
if (_connectionState.value == MqttConnectionState.CONNECTED) {
emitDenmTxMessage(payload)
publishRetained(DENM_CTRL_TOPIC, payload)
} else {
pendingDenmResync = true
}
}
/** Republish the last DENM control message after reconnecting, if it never went out. */
private fun resyncDenmOnReconnect() {
if (!pendingDenmResync) return
pendingDenmResync = false
val payload = _lastDenmPayload.value ?: return
emitDenmTxMessage(payload)
publishRetained(DENM_CTRL_TOPIC, payload)
}
/** Inject a synthetic TX message into the message stream so it shows in the V2X Monitor. */
private fun emitDenmTxMessage(payload: String) {
recordMessage(
MqttMessage(
topic = DENM_CTRL_TOPIC,
payload = payload,
timestamp = System.currentTimeMillis(),
direction = MessageDirection.TX,
)
)
}
/**
* Record a message into both the live [messages] stream (for screens currently open)
* and the persistent [topicMessages] log (survives even when no screen is collecting).
*/
private fun recordMessage(msg: MqttMessage) {
_messages.tryEmit(msg)
_topicMessages.update { current ->
val updated = ((current[msg.topic] ?: emptyList()) + msg)
.takeLast(MAX_MESSAGES_PER_TOPIC)
current + (msg.topic to updated)
}
}
// ── Public API ──────────────────────────────────────────────────────────── // ── Public API ────────────────────────────────────────────────────────────
fun connect() { fun connect() {
@@ -93,22 +210,6 @@ class MqttRepository @Inject constructor(
} }
} }
/**
* Publish a message to [topic]. No-op when not connected.
* Uses QoS 0 (fire-and-forget) by default — suitable for test CAM frames.
*/
fun publish(topic: String, payload: String, qos: Int = 0) {
val client = activeClient ?: return
if (!client.isConnected) return
scope.launch {
runCatching {
val msg = PahoMqttMessage(payload.toByteArray(Charsets.UTF_8))
msg.qos = qos
client.publish(topic, msg)
}
}
}
/** /**
* Publish a retained message to [topic] at QoS 1. No-op when not connected. * Publish a retained message to [topic] at QoS 1. No-op when not connected.
* The retain flag ensures the OBU's Use Case app receives it on (re)connect. * The retain flag ensures the OBU's Use Case app receives it on (re)connect.
@@ -185,7 +286,7 @@ class MqttRepository @Inject constructor(
override fun connectionLost(cause: Throwable?) { lostSignal.complete(cause) } override fun connectionLost(cause: Throwable?) { lostSignal.complete(cause) }
override fun messageArrived(topic: String, message: PahoMqttMessage) { override fun messageArrived(topic: String, message: PahoMqttMessage) {
_messages.tryEmit( recordMessage(
MqttMessage( MqttMessage(
topic = topic, topic = topic,
payload = message.payload.toString(Charsets.UTF_8), payload = message.payload.toString(Charsets.UTF_8),
@@ -210,15 +311,15 @@ class MqttRepository @Inject constructor(
// Bind socket to the USB tethering interface only when the cable is present. // Bind socket to the USB tethering interface only when the cable is present.
// Without this, traffic would be routed over Wi-Fi even in USB-C mode. // Without this, traffic would be routed over Wi-Fi even in USB-C mode.
if (usbActive) { if (isUsbC && usbNet != null) {
socketFactory = usbNet!!.socketFactory socketFactory = usbNet.socketFactory
} }
} }
// Connect (bridge callback → coroutine) // Connect (bridge callback → coroutine)
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine { cont ->
client.connect(options, null, object : IMqttActionListener { client.connect(options, null, object : IMqttActionListener {
override fun onSuccess(token: IMqttToken) = cont.resume(Unit) {} override fun onSuccess(token: IMqttToken) = cont.resumeWith(Result.success(Unit))
override fun onFailure(token: IMqttToken, ex: Throwable) = override fun onFailure(token: IMqttToken, ex: Throwable) =
cont.resumeWithException(ex) cont.resumeWithException(ex)
}) })
@@ -230,7 +331,7 @@ class MqttRepository @Inject constructor(
val qosArr = IntArray(topicsArr.size) { 0 } val qosArr = IntArray(topicsArr.size) { 0 }
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine { cont ->
client.subscribe(topicsArr, qosArr, null, object : IMqttActionListener { client.subscribe(topicsArr, qosArr, null, object : IMqttActionListener {
override fun onSuccess(token: IMqttToken) = cont.resume(Unit) {} override fun onSuccess(token: IMqttToken) = cont.resumeWith(Result.success(Unit))
override fun onFailure(token: IMqttToken, ex: Throwable) = override fun onFailure(token: IMqttToken, ex: Throwable) =
cont.resumeWithException(ex) cont.resumeWithException(ex)
}) })
@@ -46,6 +46,7 @@ class EventDetector(private val config: DetectionConfig = DetectionConfig()) {
private var turningStartTime = 0L private var turningStartTime = 0L
private var turningBothAgreed = false private var turningBothAgreed = false
private var peakGyroTurning = 0.0 private var peakGyroTurning = 0.0
private var turningEventFired = false
// ── Stopping state ──────────────────────────────────────────────────────── // ── Stopping state ────────────────────────────────────────────────────────
@@ -169,12 +170,16 @@ class EventDetector(private val config: DetectionConfig = DetectionConfig()) {
turningStartTime = timestamp turningStartTime = timestamp
peakGyroTurning = 0.0 peakGyroTurning = 0.0
turningBothAgreed = false turningBothAgreed = false
turningEventFired = false
} }
turningFrames++ turningFrames++
peakGyroTurning = maxOf(peakGyroTurning, gyroMagnitude) peakGyroTurning = maxOf(peakGyroTurning, gyroMagnitude)
if (gyroAbove && speedAbove && bearingAbove) turningBothAgreed = true if (gyroAbove && speedAbove && bearingAbove) turningBothAgreed = true
if (turningFrames == config.turningSustainedFrames) { // Emit once per continuous turn. Without turningEventFired, a single turn that
// stays above threshold for longer than turningSustainedFrames would re-trigger
// every turningSustainedFrames frames (e.g. a 2 s turn at 20 frames/event = 5 events).
if (turningFrames >= config.turningSustainedFrames && !turningEventFired) {
val confidence = if (turningBothAgreed) Confidence.HIGH else Confidence.LOW val confidence = if (turningBothAgreed) Confidence.HIGH else Confidence.LOW
_events.tryEmit( _events.tryEmit(
@@ -190,10 +195,11 @@ class EventDetector(private val config: DetectionConfig = DetectionConfig()) {
durationMs = timestamp - turningStartTime, durationMs = timestamp - turningStartTime,
) )
) )
turningFrames = 0 turningEventFired = true
} }
} else { } else {
turningFrames = 0 turningFrames = 0
turningEventFired = false
} }
} }
@@ -5,6 +5,7 @@ import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.content.pm.ServiceInfo
import android.hardware.Sensor import android.hardware.Sensor
import android.hardware.SensorEvent import android.hardware.SensorEvent
import android.hardware.SensorEventListener import android.hardware.SensorEventListener
@@ -12,6 +13,7 @@ import android.hardware.SensorManager
import android.location.Location import android.location.Location
import android.os.IBinder import android.os.IBinder
import android.os.Looper import android.os.Looper
import android.os.PowerManager
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationCallback import com.google.android.gms.location.LocationCallback
@@ -26,6 +28,7 @@ import com.hawhamburg.micr0bu.data.db.AppDatabase
import com.hawhamburg.micr0bu.domain.detection.DetectionConfig import com.hawhamburg.micr0bu.domain.detection.DetectionConfig
import com.hawhamburg.micr0bu.domain.detection.EventDetector import com.hawhamburg.micr0bu.domain.detection.EventDetector
import com.hawhamburg.micr0bu.domain.detection.EventType import com.hawhamburg.micr0bu.domain.detection.EventType
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -44,7 +47,16 @@ import kotlin.math.sqrt
* • Accelerometer + Gyroscope at [SensorManager.SENSOR_DELAY_GAME] (~50 Hz) * • Accelerometer + Gyroscope at [SensorManager.SENSOR_DELAY_GAME] (~50 Hz)
* • FusedLocationProviderClient at 1 Hz * • FusedLocationProviderClient at 1 Hz
* • Accel/gyro magnitudes + last GPS state → [EventDetector] → Room via [TripRepository] * • Accel/gyro magnitudes + last GPS state → [EventDetector] → Room via [TripRepository]
*
* Per the requirements doc (Section 11), Phase A is independent of OBU connectivity — no
* cable, no MQTT broker, no hardware required — so this service has no MQTT/DENM dependency.
* Detected events are recorded for trip history/CSV only; a future phase will feed them into
* the CAM-based Use Case Detection Engine (`com.hawhamburg.micr0bu.domain.usecase`) as
* corroborating signals (Section 11.4). No DENM is triggered by detected events — the only
* way to send a DENM is the manual "Send Test DENM" button in the V2X Monitor screen, kept
* solely to verify the OBU↔RSU antenna link independent of any detected event.
*/ */
@AndroidEntryPoint
class TripRecordingService : Service() { class TripRecordingService : Service() {
// ── Constants ───────────────────────────────────────────────────────────── // ── Constants ─────────────────────────────────────────────────────────────
@@ -66,6 +78,11 @@ class TripRecordingService : Service() {
private val serviceScope = CoroutineScope(Dispatchers.Default + serviceJob) private val serviceScope = CoroutineScope(Dispatchers.Default + serviceJob)
private var timerJob: Job? = null private var timerJob: Job? = null
// Held only while a trip is actively recording — keeps the CPU alive for sensor
// and GPS callbacks when the screen is off. Released immediately on stop so the
// phone can sleep normally when the app is idle.
private var wakeLock: PowerManager.WakeLock? = null
// ── Dependencies ────────────────────────────────────────────────────────── // ── Dependencies ──────────────────────────────────────────────────────────
private lateinit var sensorManager: SensorManager private lateinit var sensorManager: SensorManager
@@ -207,6 +224,14 @@ class TripRecordingService : Service() {
// ── Trip control ───────────────────────────────────────────────────────── // ── Trip control ─────────────────────────────────────────────────────────
private fun startTrip() { private fun startTrip() {
// Keep the CPU awake for the duration of the trip so sensor and GPS callbacks
// are delivered reliably when the screen is off. Released in stopTrip().
val pm = getSystemService(POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"micr0bu:TripRecording",
).also { it.acquire() }
detector.reset() detector.reset()
brakingCount = 0 brakingCount = 0
turningCount = 0 turningCount = 0
@@ -272,11 +297,21 @@ class TripRecordingService : Service() {
registerSensors() registerSensors()
requestLocationUpdates() requestLocationUpdates()
// Start foreground ASAP (within 5 s required by Android) // Start foreground ASAP (within 5 s required by Android).
startForeground(NOTIFICATION_ID, buildNotification(0L, 0)) // Specify FOREGROUND_SERVICE_TYPE_LOCATION so Android 10+ knows why we need
// continuous location access, and so the notification is re-posted automatically
// if the user dismisses it (Android 13+ allows users to swipe foreground
// service notifications away, but the system re-shows them after ~1 s).
startForeground(
NOTIFICATION_ID,
buildNotification(0L),
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION,
)
} }
private fun stopTrip() { private fun stopTrip() {
wakeLock?.let { if (it.isHeld) it.release() }
wakeLock = null
timerJob?.cancel() timerJob?.cancel()
unregisterSensors() unregisterSensors()
removeLocationUpdates() removeLocationUpdates()
@@ -377,7 +412,7 @@ class TripRecordingService : Service() {
.createNotificationChannel(channel) .createNotificationChannel(channel)
} }
private fun buildNotification(elapsedSeconds: Long, eventCount: Int) = private fun buildNotification(elapsedSeconds: Long) =
NotificationCompat.Builder(this, CHANNEL_ID) NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Recording trip") .setContentTitle("Recording trip")
.setContentText( .setContentText(
@@ -390,17 +425,22 @@ class TripRecordingService : Service() {
.setContentIntent( .setContentIntent(
PendingIntent.getActivity( PendingIntent.getActivity(
this, 0, this, 0,
Intent(this, MainActivity::class.java), Intent(this, MainActivity::class.java).apply {
PendingIntent.FLAG_IMMUTABLE, // Bring the existing activity to the front rather than
// launching a second instance on top of the running one.
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or
Intent.FLAG_ACTIVITY_CLEAR_TOP
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
) )
) )
.setAutoCancel(false)
.build() .build()
private fun updateNotification() { private fun updateNotification() {
val elapsed = TripServiceBus.state.value.elapsedSeconds val elapsed = TripServiceBus.state.value.elapsedSeconds
val total = brakingCount + turningCount + stoppingCount
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIFICATION_ID, buildNotification(elapsed, total)) nm.notify(NOTIFICATION_ID, buildNotification(elapsed))
} }
private fun formatElapsed(seconds: Long): String { private fun formatElapsed(seconds: Long): String {
@@ -47,6 +47,7 @@ sealed class Screen(val route: String, val labelRes: Int) {
data object SettingsUnits : Screen("settings/units", R.string.settings_units) data object SettingsUnits : Screen("settings/units", R.string.settings_units)
data object SettingsConnection : Screen("settings/connection", R.string.settings_connection) data object SettingsConnection : Screen("settings/connection", R.string.settings_connection)
data object SettingsMqtt : Screen("settings/mqtt", R.string.settings_mqtt_section) data object SettingsMqtt : Screen("settings/mqtt", R.string.settings_mqtt_section)
data object SettingsUseCaseAlerts : Screen("settings/usecases", R.string.settings_usecase_alerts)
data object SettingsDeveloper : Screen("settings/developer", R.string.settings_developer) data object SettingsDeveloper : Screen("settings/developer", R.string.settings_developer)
data object SettingsAbout : Screen("settings/about", R.string.settings_about) data object SettingsAbout : Screen("settings/about", R.string.settings_about)
} }
@@ -61,6 +61,7 @@ fun DashboardScreen(
activeTransport: TransportType = TransportType.USB_C, activeTransport: TransportType = TransportType.USB_C,
usbCableConnected: Boolean = false, usbCableConnected: Boolean = false,
obuStationTypeWarning: Boolean = false, obuStationTypeWarning: Boolean = false,
obuStationType: Int? = null,
onNavigateToConnection: () -> Unit, onNavigateToConnection: () -> Unit,
onNavigateToSensors: () -> Unit, onNavigateToSensors: () -> Unit,
onNavigateToMap: () -> Unit, onNavigateToMap: () -> Unit,
@@ -195,7 +196,10 @@ fun DashboardScreen(
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
) )
Text( Text(
stringResource(R.string.dash_station_type_warning), buildString {
append(stringResource(R.string.dash_station_type_warning))
if (obuStationType != null) append(" (got $obuStationType)")
},
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = Color(0xFFFFC107), color = Color(0xFFFFC107),
) )
@@ -65,6 +65,11 @@ import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.mqtt.MessageDirection import com.hawhamburg.micr0bu.data.mqtt.MessageDirection
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.mqtt.MqttMessage import com.hawhamburg.micr0bu.data.mqtt.MqttMessage
import com.hawhamburg.micr0bu.domain.cam.CamParser
import com.hawhamburg.micr0bu.domain.denm.DenmUseCase
import com.hawhamburg.micr0bu.domain.usecase.AlertLevel
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONException import org.json.JSONException
@@ -86,6 +91,12 @@ private val DisconnectedGrayBg = Color(0xFF1C2733)
private val DenmRed = Color(0xFFE53935) private val DenmRed = Color(0xFFE53935)
private val DenmRedBg = Color(0xFF3A0A0A) private val DenmRedBg = Color(0xFF3A0A0A)
private val DenmAmber = Color(0xFFFFC107) private val DenmAmber = Color(0xFFFFC107)
private val InfoBlue = Color(0xFF64B5F6)
private val InfoBlueBg = Color(0xFF10243A)
private val AwarenessAmber = Color(0xFFFFC107)
private val AwarenessAmberBg = Color(0xFF3A2D00)
private val WarningRed = Color(0xFFE53935)
private val WarningRedBg = Color(0xFF3A0A0A)
@Composable @Composable
fun MqttTopicViewerScreen( fun MqttTopicViewerScreen(
@@ -97,6 +108,9 @@ fun MqttTopicViewerScreen(
val autoScroll by viewModel.autoScroll.collectAsState() val autoScroll by viewModel.autoScroll.collectAsState()
val denmActive by viewModel.denmActive.collectAsState() val denmActive by viewModel.denmActive.collectAsState()
val lastDenmPayload by viewModel.lastDenmPayload.collectAsState() val lastDenmPayload by viewModel.lastDenmPayload.collectAsState()
val activeDenmUseCase by viewModel.activeDenmUseCase.collectAsState()
val useCaseAlerts by viewModel.useCaseAlerts.collectAsState()
val ownStationId by viewModel.ownStationId.collectAsState()
// Sort: sys/ topics first (heartbeat/health), then alphabetical // Sort: sys/ topics first (heartbeat/health), then alphabetical
val sortedTopics = topicMessages.keys.sortedWith( val sortedTopics = topicMessages.keys.sortedWith(
@@ -172,6 +186,8 @@ fun MqttTopicViewerScreen(
connectionState = connectionState, connectionState = connectionState,
denmActive = denmActive, denmActive = denmActive,
lastDenmPayload = lastDenmPayload, lastDenmPayload = lastDenmPayload,
activeDenmUseCase = activeDenmUseCase,
useCaseAlerts = useCaseAlerts,
onSelectTopic = { viewModel.selectTopic(it) }, onSelectTopic = { viewModel.selectTopic(it) },
onSendDenm = { viewModel.sendDenm() }, onSendDenm = { viewModel.sendDenm() },
onStopDenm = { viewModel.stopDenm() }, onStopDenm = { viewModel.stopDenm() },
@@ -180,6 +196,7 @@ fun MqttTopicViewerScreen(
MessageDetailPane( MessageDetailPane(
messages = topicMessages[selectedTopic] ?: emptyList(), messages = topicMessages[selectedTopic] ?: emptyList(),
autoScroll = autoScroll, autoScroll = autoScroll,
ownStationId = ownStationId,
) )
} }
} }
@@ -194,6 +211,8 @@ private fun TopicListPane(
connectionState: MqttConnectionState, connectionState: MqttConnectionState,
denmActive: Boolean, denmActive: Boolean,
lastDenmPayload: String?, lastDenmPayload: String?,
activeDenmUseCase: String?,
useCaseAlerts: List<UseCaseAlert>,
onSelectTopic: (String) -> Unit, onSelectTopic: (String) -> Unit,
onSendDenm: () -> Unit, onSendDenm: () -> Unit,
onStopDenm: () -> Unit, onStopDenm: () -> Unit,
@@ -202,11 +221,17 @@ private fun TopicListPane(
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// ── DENM TX Control card ──────────────────────────────────────────── // ── CAM-based Use Case Alert panel (Section 10.2 / 10.4) ─────────────
UseCaseAlertPanel(alerts = useCaseAlerts, isConnected = isConnected)
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
// ── DENM TX Control card — manual/test trigger only, not use-case-driven ──
DenmTxCard( DenmTxCard(
isConnected = isConnected, isConnected = isConnected,
denmActive = denmActive, denmActive = denmActive,
lastDenmPayload = lastDenmPayload, lastDenmPayload = lastDenmPayload,
activeDenmUseCase = activeDenmUseCase,
onSendDenm = onSendDenm, onSendDenm = onSendDenm,
onStopDenm = onStopDenm, onStopDenm = onStopDenm,
) )
@@ -310,6 +335,7 @@ private fun TopicRow(
private fun MessageDetailPane( private fun MessageDetailPane(
messages: List<MqttMessage>, messages: List<MqttMessage>,
autoScroll: Boolean, autoScroll: Boolean,
ownStationId: Long?,
) { ) {
if (messages.isEmpty()) { if (messages.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -331,18 +357,29 @@ private fun MessageDetailPane(
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) { LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
itemsIndexed(reversed, key = { index, msg -> "${msg.topic}-${msg.timestamp}-$index" }) { _, msg -> itemsIndexed(reversed, key = { index, msg -> "${msg.topic}-${msg.timestamp}-$index" }) { _, msg ->
MessageRow(msg) MessageRow(msg, ownStationId)
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)) HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f))
} }
} }
} }
@Composable @Composable
private fun MessageRow(msg: MqttMessage) { private fun MessageRow(msg: MqttMessage, ownStationId: Long?) {
// Show TX badge for all outbound messages; RX badge only on DENM topics // CAM messages get an OWN/REMOTE badge instead of a TX/RX badge — that's the
// (to avoid noisy badges on high-frequency CAM/SPAT traffic). // distinction the V2X Monitor and Use Case Alert panel actually care about (Section 10.4).
val showBadge = msg.direction == MessageDirection.TX || val isCamTopic = msg.topic.substringAfterLast('/') == "cam"
val camIsOwn: Boolean? = if (isCamTopic) {
remember(msg.payload, ownStationId) {
CamParser.parse(msg.payload, ownStationId, msg.timestamp)?.isOwn
}
} else null
// For non-CAM topics: show TX badge for all outbound messages; RX badge only on DENM
// topics (to avoid noisy badges on high-frequency SPAT/MAP/CPM traffic).
val showDirectionBadge = camIsOwn == null && (
msg.direction == MessageDirection.TX ||
(msg.direction == MessageDirection.RX && msg.topic.contains("denm", ignoreCase = true)) (msg.direction == MessageDirection.RX && msg.topic.contains("denm", ignoreCase = true))
)
Column( Column(
modifier = Modifier modifier = Modifier
@@ -359,7 +396,8 @@ private fun MessageRow(msg: MqttMessage) {
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
) )
if (showBadge) DirectionBadge(msg.direction) if (camIsOwn != null) OwnRemoteBadge(isOwn = camIsOwn)
else if (showDirectionBadge) DirectionBadge(msg.direction)
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Box( Box(
@@ -389,6 +427,7 @@ private fun DenmTxCard(
isConnected: Boolean, isConnected: Boolean,
denmActive: Boolean, denmActive: Boolean,
lastDenmPayload: String?, lastDenmPayload: String?,
activeDenmUseCase: String?,
onSendDenm: () -> Unit, onSendDenm: () -> Unit,
onStopDenm: () -> Unit, onStopDenm: () -> Unit,
) { ) {
@@ -450,7 +489,7 @@ private fun DenmTxCard(
shape = RoundedCornerShape(4.dp), shape = RoundedCornerShape(4.dp),
) { ) {
Text( Text(
"hln-sv", activeDenmUseCase ?: DenmUseCase.STATIONARY.id,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = if (denmActive) DenmRed color = if (denmActive) DenmRed
else MaterialTheme.colorScheme.onSurfaceVariant, else MaterialTheme.colorScheme.onSurfaceVariant,
@@ -587,6 +626,129 @@ private fun DirectionBadge(direction: MessageDirection) {
} }
} }
// ── OWN / REMOTE badge (CAM messages) ─────────────────────────────────────────
@Composable
private fun OwnRemoteBadge(isOwn: Boolean) {
val (text, fg, bg) = if (isOwn) {
Triple("OWN", ConnectedGreen, ConnectedGreenBg)
} else {
Triple("REMOTE", DenmAmber, Color(0xFF3A2D00))
}
Surface(color = bg, shape = RoundedCornerShape(3.dp)) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = fg,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp),
)
}
}
// ── CAM-based Use Case Alert panel ────────────────────────────────────────────
@Composable
private fun UseCaseAlertPanel(alerts: List<UseCaseAlert>, isConnected: Boolean) {
val topAlert = alerts.firstOrNull()
val cardBg = when (topAlert?.alertLevel) {
AlertLevel.WARNING -> WarningRedBg
AlertLevel.AWARENESS -> AwarenessAmberBg
AlertLevel.INFO -> InfoBlueBg
null -> MaterialTheme.colorScheme.surfaceVariant
}
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
colors = CardDefaults.cardColors(containerColor = cardBg),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
stringResource(R.string.mqtt_usecase_panel_title),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(8.dp))
when {
!isConnected -> Text(
stringResource(R.string.mqtt_usecase_not_connected),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
alerts.isEmpty() -> Text(
stringResource(R.string.mqtt_usecase_none_active),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
alerts.forEach { alert -> UseCaseAlertRow(alert) }
}
}
}
}
}
/** Maps a use case to its human-readable narrative string resource (Section 10.4). */
private fun UseCaseType.narrativeStringRes(): Int = when (this) {
UseCaseType.IMA_B -> R.string.usecase_narrative_ima_b
UseCaseType.IMA_S -> R.string.usecase_narrative_ima_s
UseCaseType.RTW_B -> R.string.usecase_narrative_rtw_b
UseCaseType.LTW_B -> R.string.usecase_narrative_ltw_b
UseCaseType.SMVA_BCW_B -> R.string.usecase_narrative_smva_bcw_b
}
@Composable
private fun UseCaseAlertRow(alert: UseCaseAlert) {
val (fg, bg) = when (alert.alertLevel) {
AlertLevel.WARNING -> WarningRed to WarningRedBg
AlertLevel.AWARENESS -> AwarenessAmber to AwarenessAmberBg
AlertLevel.INFO -> InfoBlue to InfoBlueBg
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Surface(color = bg, shape = RoundedCornerShape(4.dp)) {
Text(
text = alert.alertLevel.name,
style = MaterialTheme.typography.labelSmall,
color = fg,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
Column(modifier = Modifier.weight(1f)) {
// Primary, human-readable line — what the rider actually needs to read at a glance.
Text(
text = stringResource(alert.useCase.narrativeStringRes(), alert.timeToConflictSec),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
// Secondary technical line — use case ID + the raw numbers + which signal fired
// (yaw-rate / heading-trend / static-heuristic / distance-trend), so the detection
// logic stays verifiable rather than a black box.
Text(
text = "${alert.useCase.label} · " + stringResource(
R.string.mqtt_usecase_detail,
alert.remoteStationId,
alert.distanceMeters,
alert.closingSpeedMps,
alert.timeToConflictSec,
alert.signalNote,
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// ── Connection status chip ──────────────────────────────────────────────────── // ── Connection status chip ────────────────────────────────────────────────────
@Composable @Composable
@@ -16,6 +16,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FiberManualRecord import androidx.compose.material.icons.filled.FiberManualRecord
import androidx.compose.material.icons.filled.TableChart import androidx.compose.material.icons.filled.TableChart
import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@@ -25,6 +26,7 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -44,10 +46,33 @@ fun RecordingScreen(
state: SensorUiState, state: SensorUiState,
mqttConnectionState: MqttConnectionState, mqttConnectionState: MqttConnectionState,
tripServiceState: TripServiceBus.State, tripServiceState: TripServiceBus.State,
showBatteryOptPrompt: Boolean,
onToggleRecording: () -> Unit, onToggleRecording: () -> Unit,
onOpenSessionLog: () -> Unit, onOpenSessionLog: () -> Unit,
onOpenBatterySettings: () -> Unit,
onDismissBatteryOptPrompt: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (showBatteryOptPrompt) {
AlertDialog(
onDismissRequest = {}, // force explicit choice
title = { Text("Allow background GPS") },
text = {
Text(
"To keep GPS running when the screen turns off during a ride, " +
"exempt MicrOBU from battery optimisation. " +
"The app will only use this during active recording."
)
},
confirmButton = {
Button(onClick = onOpenBatterySettings) { Text("Open settings") }
},
dismissButton = {
TextButton(onClick = onDismissBatteryOptPrompt) { Text("Don't show again") }
},
)
}
Column( Column(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
@@ -30,6 +30,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -38,9 +39,10 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hawhamburg.micr0bu.R import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.readSessionCsvContent import com.hawhamburg.micr0bu.data.saveSessionCsvToUri
import com.hawhamburg.micr0bu.data.sessionFileName import com.hawhamburg.micr0bu.data.sessionFileName
import com.hawhamburg.micr0bu.data.shareSessionCsv import com.hawhamburg.micr0bu.data.shareSessionCsv
import kotlinx.coroutines.launch
import com.hawhamburg.micr0bu.viewmodel.RecordingSession import com.hawhamburg.micr0bu.viewmodel.RecordingSession
import com.hawhamburg.micr0bu.viewmodel.SensorUiState import com.hawhamburg.micr0bu.viewmodel.SensorUiState
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -56,6 +58,7 @@ fun SessionLogScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope()
var pendingDelete by remember { mutableStateOf<RecordingSession?>(null) } var pendingDelete by remember { mutableStateOf<RecordingSession?>(null) }
var pendingSave by remember { mutableStateOf<RecordingSession?>(null) } var pendingSave by remember { mutableStateOf<RecordingSession?>(null) }
@@ -64,8 +67,8 @@ fun SessionLogScreen(
) { uri -> ) { uri ->
uri ?: return@rememberLauncherForActivityResult uri ?: return@rememberLauncherForActivityResult
val session = pendingSave ?: return@rememberLauncherForActivityResult val session = pendingSave ?: return@rememberLauncherForActivityResult
context.contentResolver.openOutputStream(uri)?.use { it.write(readSessionCsvContent(context, session).toByteArray(Charsets.UTF_8)) }
pendingSave = null pendingSave = null
scope.launch { saveSessionCsvToUri(context, session, uri) }
} }
pendingDelete?.let { session -> pendingDelete?.let { session ->
@@ -103,7 +106,7 @@ fun SessionLogScreen(
SessionCard( SessionCard(
session = session, session = session,
index = state.sessions.size - index, index = state.sessions.size - index,
onShare = { shareSessionCsv(context, session) }, onShare = { scope.launch { shareSessionCsv(context, session) } },
onSaveToDevice = { pendingSave = session; saveLauncher.launch(sessionFileName(session)) }, onSaveToDevice = { pendingSave = session; saveLauncher.launch(sessionFileName(session)) },
onDelete = { pendingDelete = session }, onDelete = { pendingDelete = session },
) )
@@ -47,6 +47,8 @@ import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import com.hawhamburg.micr0bu.R import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.mqtt.MqttPrefs import com.hawhamburg.micr0bu.data.mqtt.MqttPrefs
import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionConfig
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
import com.hawhamburg.micr0bu.viewmodel.SensorUiState import com.hawhamburg.micr0bu.viewmodel.SensorUiState
private data class LangOption(val tag: String, val flag: String, val labelRes: Int) private data class LangOption(val tag: String, val flag: String, val labelRes: Int)
@@ -66,6 +68,7 @@ fun SettingsScreen(
onOpenUnits: () -> Unit, onOpenUnits: () -> Unit,
onOpenConnection: () -> Unit, onOpenConnection: () -> Unit,
onOpenMqtt: () -> Unit, onOpenMqtt: () -> Unit,
onOpenUseCaseAlerts: () -> Unit,
onOpenDeveloper: () -> Unit, onOpenDeveloper: () -> Unit,
onOpenAbout: () -> Unit, onOpenAbout: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -99,6 +102,8 @@ fun SettingsScreen(
RowDivider() RowDivider()
MenuRow(stringResource(R.string.settings_mqtt_section), onOpenMqtt) MenuRow(stringResource(R.string.settings_mqtt_section), onOpenMqtt)
RowDivider() RowDivider()
MenuRow(stringResource(R.string.settings_usecase_alerts), onOpenUseCaseAlerts)
RowDivider()
MenuRow(stringResource(R.string.settings_developer), onOpenDeveloper) MenuRow(stringResource(R.string.settings_developer), onOpenDeveloper)
RowDivider() RowDivider()
MenuRow(stringResource(R.string.settings_about), onOpenAbout) MenuRow(stringResource(R.string.settings_about), onOpenAbout)
@@ -284,7 +289,57 @@ fun MqttSettingsScreen(
) { ) {
SubScreen(stringResource(R.string.settings_mqtt_section), onBack) { SubScreen(stringResource(R.string.settings_mqtt_section), onBack) {
SectionCard { SectionCard {
MqttSettingsSection(prefs = mqttPrefs, onChange = onMqttPrefsChange) MqttSettingsSection(prefs = mqttPrefs, onChange = onMqttPrefsChange, onSave = onBack)
}
}
}
@Composable
fun UseCaseAlertsSettingsScreen(
enabledMap: Map<UseCaseType, Boolean>,
onToggle: (UseCaseType, Boolean) -> Unit,
onBack: () -> Unit,
) {
val config = remember { UseCaseDetectionConfig() }
SubScreen(stringResource(R.string.settings_usecase_alerts), onBack) {
Text(
stringResource(R.string.settings_usecase_alerts_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
SectionCard {
UseCaseType.entries.forEachIndexed { index, type ->
SettingToggleRow(
label = "${type.label} · ${type.c2cId}",
checked = enabledMap[type] ?: true,
onCheckedChange = { onToggle(type, it) },
)
if (index != UseCaseType.entries.lastIndex) Divider()
}
}
Text(
stringResource(R.string.settings_usecase_alert_levels_title),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
SectionCard {
DisabledRow(
stringResource(R.string.settings_usecase_level_warning),
stringResource(R.string.settings_usecase_ttc_le, config.ttcWarningSec),
)
Divider()
DisabledRow(
stringResource(R.string.settings_usecase_level_awareness),
stringResource(R.string.settings_usecase_ttc_le, config.ttcAwarenessSec),
)
Divider()
DisabledRow(
stringResource(R.string.settings_usecase_level_info),
stringResource(R.string.settings_usecase_ttc_le, config.ttcInfoSec),
)
} }
} }
} }
@@ -334,7 +389,11 @@ private fun SectionCard(content: @Composable () -> Unit) {
} }
@Composable @Composable
private fun MqttSettingsSection(prefs: MqttPrefs, onChange: (MqttPrefs) -> Unit) { private fun MqttSettingsSection(
prefs: MqttPrefs,
onChange: (MqttPrefs) -> Unit,
onSave: () -> Unit = {},
) {
// Key each state to the corresponding saved pref so fields re-sync after an // Key each state to the corresponding saved pref so fields re-sync after an
// external save (e.g. via the Save button), while preserving in-progress edits. // external save (e.g. via the Save button), while preserving in-progress edits.
var ip by remember(prefs.brokerIp) { mutableStateOf(prefs.brokerIp) } var ip by remember(prefs.brokerIp) { mutableStateOf(prefs.brokerIp) }
@@ -401,6 +460,7 @@ private fun MqttSettingsSection(prefs: MqttPrefs, onChange: (MqttPrefs) -> Unit)
password = password, password = password,
) )
) )
onSave()
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
@@ -2,7 +2,7 @@ package com.hawhamburg.micr0bu.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.hawhamburg.micr0bu.data.mqtt.MessageDirection import com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.mqtt.MqttMessage import com.hawhamburg.micr0bu.data.mqtt.MqttMessage
import com.hawhamburg.micr0bu.data.mqtt.MqttPreferences import com.hawhamburg.micr0bu.data.mqtt.MqttPreferences
@@ -10,6 +10,9 @@ import com.hawhamburg.micr0bu.data.mqtt.MqttPrefs
import com.hawhamburg.micr0bu.data.mqtt.MqttRepository import com.hawhamburg.micr0bu.data.mqtt.MqttRepository
import com.hawhamburg.micr0bu.data.transport.TransportType import com.hawhamburg.micr0bu.data.transport.TransportType
import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector
import com.hawhamburg.micr0bu.domain.denm.DenmUseCase
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -17,29 +20,23 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.json.JSONObject import org.json.JSONObject
import javax.inject.Inject import javax.inject.Inject
private const val MAX_MESSAGES_PER_TOPIC = 50
private const val DENM_CTRL_TOPIC = "v2x-uca/input/denmtrg"
private const val DENM_USE_CASE = "hln-sv"
@HiltViewModel @HiltViewModel
class MqttViewModel @Inject constructor( class MqttViewModel @Inject constructor(
private val repo: MqttRepository, private val repo: MqttRepository,
private val prefs: MqttPreferences, private val prefs: MqttPreferences,
private val usbDetector: UsbNetworkDetector, private val usbDetector: UsbNetworkDetector,
private val camUseCaseRepository: CamUseCaseRepository,
) : ViewModel() { ) : ViewModel() {
// ── MQTT connection & messages ──────────────────────────────────────────── // ── MQTT connection & messages ────────────────────────────────────────────
val connectionState: StateFlow<MqttConnectionState> = repo.connectionState val connectionState: StateFlow<MqttConnectionState> = repo.connectionState
private val _topicMessages = MutableStateFlow<Map<String, List<MqttMessage>>>(emptyMap()) val topicMessages: StateFlow<Map<String, List<MqttMessage>>> = repo.topicMessages
val topicMessages: StateFlow<Map<String, List<MqttMessage>>> = _topicMessages.asStateFlow()
private val _selectedTopic = MutableStateFlow<String?>(null) private val _selectedTopic = MutableStateFlow<String?>(null)
val selectedTopic: StateFlow<String?> = _selectedTopic.asStateFlow() val selectedTopic: StateFlow<String?> = _selectedTopic.asStateFlow()
@@ -69,11 +66,16 @@ class MqttViewModel @Inject constructor(
// ── OBU identity (parsed from v2x/rx/obu_gnss own_info) ────────────────── // ── OBU identity (parsed from v2x/rx/obu_gnss own_info) ──────────────────
private val _obuStationType = MutableStateFlow<Int?>(null)
/** /**
* stationType from the OBU's own_info (v2x/rx/obu_gnss). Should be 2 (cyclist/VRU) per * stationType from the OBU's own_info (v2x/rx/obu_gnss). Should be 2 (cyclist/VRU) per
* ETSI EN 302 637-2 Table 1. Null until the first obu_gnss message arrives. * ETSI EN 302 637-2 Table 1. Null until the first obu_gnss message arrives.
*/ */
private val _obuStationType = MutableStateFlow<Int?>(null)
/**
* The raw stationType value last reported by the OBU. Null until the first
* obu_gnss message arrives. Exposed so the UI can show the actual value.
*/
val obuStationType: StateFlow<Int?> = _obuStationType.asStateFlow() val obuStationType: StateFlow<Int?> = _obuStationType.asStateFlow()
/** /**
@@ -87,30 +89,42 @@ class MqttViewModel @Inject constructor(
// ── DENM transmission ───────────────────────────────────────────────────── // ── DENM transmission ─────────────────────────────────────────────────────
private val _denmActive = MutableStateFlow(false) /** True while a DENM use case is actively broadcasting on the OBU. */
/** True while the DENM use case is actively broadcasting on the OBU. */ val denmActive: StateFlow<Boolean> = repo.denmActive
val denmActive: StateFlow<Boolean> = _denmActive.asStateFlow()
private val _lastDenmPayload = MutableStateFlow<String?>(null)
/** JSON string of the most recently transmitted DENM control message. */ /** JSON string of the most recently transmitted DENM control message. */
val lastDenmPayload: StateFlow<String?> = _lastDenmPayload.asStateFlow() val lastDenmPayload: StateFlow<String?> = repo.lastDenmPayload
/** Tracks which use case is currently active so Stop uses the same usecase field. */ /** The use case id currently active on the OBU, if any (e.g. for showing in the UI). */
private var activeDenmUseCase: String? = null val activeDenmUseCase: StateFlow<String?> = repo.activeDenmUseCase
// ── CAM-based Use Case Detection (Section 10.2 / 10.4) ────────────────────
// Entirely separate from the DENM transmission above: this consumes CAM only, raises
// local HMI alerts, and never triggers an outbound V2X message.
/** The ego OBU's own station ID, learned from v2x/rx/obu_gnss's own_info. */
val ownStationId: StateFlow<Long?> = camUseCaseRepository.ownStationId
/** Active CAM-based use case alerts, filtered to the use cases enabled in Settings. */
val useCaseAlerts: StateFlow<List<UseCaseAlert>> = camUseCaseRepository.enabledAlerts
/** Per-use-case enable/disable state (Settings > Use Case Alerts). */
val useCaseEnabledMap: StateFlow<Map<UseCaseType, Boolean>> = camUseCaseRepository.enabledMap
/** True if [stationId] is the ego OBU's own — used for OWN/REMOTE badges in the raw message list. */
fun isOwnStationId(stationId: Long): Boolean = camUseCaseRepository.isOwnStationId(stationId)
fun setUseCaseEnabled(type: UseCaseType, enabled: Boolean) {
camUseCaseRepository.setUseCaseEnabled(type, enabled)
}
// ── Init ────────────────────────────────────────────────────────────────── // ── Init ──────────────────────────────────────────────────────────────────
init { init {
viewModelScope.launch { viewModelScope.launch {
// Per-topic message lists now live in MqttRepository (survives screen close);
// here we just watch for own_info to track the OBU's reported stationType.
repo.messages.collect { msg -> repo.messages.collect { msg ->
// Route into per-topic message lists
_topicMessages.update { current ->
val updated = ((current[msg.topic] ?: emptyList()) + msg)
.takeLast(MAX_MESSAGES_PER_TOPIC)
current + (msg.topic to updated)
}
// Check stationType from own_info — must be 2 (cyclist/VRU)
if (msg.topic == "v2x/rx/obu_gnss") { if (msg.topic == "v2x/rx/obu_gnss") {
runCatching { runCatching {
val stType = JSONObject(msg.payload) val stType = JSONObject(msg.payload)
@@ -139,52 +153,19 @@ class MqttViewModel @Inject constructor(
/** /**
* Publish a uca-denmctrl activate message with the retain flag so the OBU's Use Case app * Publish a uca-denmctrl activate message with the retain flag so the OBU's Use Case app
* receives it on any (re)connect. Only one use case may be active at a time. * receives it on any (re)connect. Only one use case may be active at a time
* (no-op if another use case, manual or automatic, is already active).
*/ */
fun sendDenm(useCase: String = DENM_USE_CASE) { fun sendDenm(useCase: String = DenmUseCase.STATIONARY.id) {
if (_denmActive.value) return // prevent concurrent activations repo.activateDenm(useCase)
val payload = buildDenmPayload(useCase = useCase, active = true)
_lastDenmPayload.value = payload
_denmActive.value = true
activeDenmUseCase = useCase
emitTxMessage(DENM_CTRL_TOPIC, payload)
repo.publishRetained(DENM_CTRL_TOPIC, payload)
} }
/** /**
* Publish a uca-denmctrl deactivate message (retained). * Publish a uca-denmctrl deactivate message (retained) for whichever use case is
* Uses the same usecase string as the activate call so the OBU phases out the correct DENM. * currently active.
*/ */
fun stopDenm() { fun stopDenm() {
if (!_denmActive.value) return repo.deactivateDenm()
val useCase = activeDenmUseCase ?: DENM_USE_CASE
val payload = buildDenmPayload(useCase = useCase, active = false)
_lastDenmPayload.value = payload
_denmActive.value = false
activeDenmUseCase = null
emitTxMessage(DENM_CTRL_TOPIC, payload)
repo.publishRetained(DENM_CTRL_TOPIC, payload)
}
private fun buildDenmPayload(useCase: String, active: Boolean): String =
"""{"type":"uca-denmctrl","active":$active,"usecase":"$useCase","params":{}}"""
/**
* Inject a synthetic TX [MqttMessage] into the local topic map so outbound messages
* appear in the V2X Monitor alongside received traffic.
*/
private fun emitTxMessage(topic: String, payload: String) {
val msg = MqttMessage(
topic = topic,
payload = payload,
timestamp = System.currentTimeMillis(),
direction = MessageDirection.TX,
)
_topicMessages.update { current ->
val updated = ((current[topic] ?: emptyList()) + msg)
.takeLast(MAX_MESSAGES_PER_TOPIC)
current + (topic to updated)
}
} }
// ── Lifecycle ───────────────────────────────────────────────────────────── // ── Lifecycle ─────────────────────────────────────────────────────────────
@@ -1,7 +1,9 @@
package com.hawhamburg.micr0bu.viewmodel package com.hawhamburg.micr0bu.viewmodel
import android.app.Application import android.app.Application
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.PowerManager
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.hawhamburg.micr0bu.data.TripRepository import com.hawhamburg.micr0bu.data.TripRepository
@@ -27,6 +29,32 @@ class TripRecordingViewModel(application: Application) : AndroidViewModel(applic
private val repository = TripRepository(AppDatabase.getInstance(application)) private val repository = TripRepository(AppDatabase.getInstance(application))
// ── Battery optimisation prompt ───────────────────────────────────────────
// Shown once on the Recording screen to ask the user to exempt the app so
// GPS callbacks are not throttled when the screen turns off during a trip.
// "Don't show again" persists across sessions via SharedPreferences.
private val appPrefs = application.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
private val pm = application.getSystemService(Context.POWER_SERVICE) as PowerManager
private val _showBatteryOptPrompt = MutableStateFlow(shouldShowBatteryPrompt())
val showBatteryOptPrompt: StateFlow<Boolean> = _showBatteryOptPrompt.asStateFlow()
private fun shouldShowBatteryPrompt(): Boolean =
!pm.isIgnoringBatteryOptimizations(getApplication<Application>().packageName) &&
!appPrefs.getBoolean("battery_opt_dismissed", false)
/** Called when user taps "Don't show again" — suppresses the prompt permanently. */
fun dismissBatteryOptPrompt() {
appPrefs.edit().putBoolean("battery_opt_dismissed", true).apply()
_showBatteryOptPrompt.value = false
}
/** Re-checks the system state — call after returning from battery settings. */
fun recheckBatteryOpt() {
_showBatteryOptPrompt.value = shouldShowBatteryPrompt()
}
// ── Service state (live during a recording) ─────────────────────────────── // ── Service state (live during a recording) ───────────────────────────────
/** Live recording state forwarded from [TripServiceBus]. */ /** Live recording state forwarded from [TripServiceBus]. */
+25 -3
View File
@@ -165,18 +165,40 @@
<string name="mqtt_no_topics_hint">Mit der OBU verbinden und auf V2X-Verkehr warten</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> <string name="mqtt_no_messages">Noch keine Nachrichten zu diesem Thema</string>
<!-- DENM TX --> <!-- DENM TX — nur manueller Antennen-/RSU-Reichweitentest, nicht Use-Case-gesteuert -->
<string name="mqtt_last_tx">Zuletzt gesendete Nutzlast:</string> <string name="mqtt_last_tx">Zuletzt gesendete Nutzlast:</string>
<string name="mqtt_denm_tx_title">DENM-Übertragung</string> <string name="mqtt_denm_tx_title">DENM-Übertragung (manueller Test)</string>
<string name="mqtt_send_denm">Test-DENM senden</string> <string name="mqtt_send_denm">Test-DENM senden</string>
<string name="mqtt_stop_denm">DENM stoppen</string> <string name="mqtt_stop_denm">DENM stoppen</string>
<string name="mqtt_denm_use_case_desc">Liegengebliebenes Fahrzeug (causeCode 94)</string> <string name="mqtt_denm_use_case_desc">Liegengebliebenes Fahrzeug (causeCode 94)</string>
<string name="mqtt_denm_not_connected">Mit OBU verbinden, um DENM-Auslösung zu aktivieren</string> <string name="mqtt_denm_not_connected">Mit OBU verbinden, um DENM-Auslösung zu aktivieren</string>
<string name="mqtt_denm_active">DENM aktiv — OBU sendet über ITS-G5</string> <string name="mqtt_denm_active">DENM aktiv — OBU sendet über ITS-G5</string>
<string name="mqtt_denm_hint">Sendet uca-denmctrl (retained) an v2x-uca/input/denmtrg</string> <string name="mqtt_denm_hint">Manueller Antennen-/RSU-Reichweitentest — sendet uca-denmctrl (retained) an v2x-uca/input/denmtrg. Wird nicht durch erkannte Ereignisse oder Use-Case-Alarme ausgelöst.</string>
<string name="mqtt_denm_show">Letztes TX anzeigen</string> <string name="mqtt_denm_show">Letztes TX anzeigen</string>
<string name="mqtt_denm_hide">Ausblenden</string> <string name="mqtt_denm_hide">Ausblenden</string>
<!-- CAM-basiertes Use-Case-Alarm-Panel -->
<string name="mqtt_usecase_panel_title">CAM-Use-Case-Alarme</string>
<string name="mqtt_usecase_not_connected">Mit OBU verbinden, um CAM-basierte Use Cases auszuwerten</string>
<string name="mqtt_usecase_none_active">Keine aktiven Use-Case-Alarme</string>
<string name="mqtt_usecase_detail">Station %1$d · %2$.0f m · Annäherung %3$.1f m/s · TTC %4$.1f s · %5$s</string>
<!-- Verständliche Use-Case-Beschreibungen -->
<string name="usecase_narrative_ima_b">Kreuzendes Fahrzeug — %1$.0f s bis Konflikt</string>
<string name="usecase_narrative_ima_s">Stehendes Fahrzeug könnte losfahren — %1$.0f s</string>
<string name="usecase_narrative_rtw_b">Auto biegt rechts auf dich zu — %1$.0f s</string>
<string name="usecase_narrative_ltw_b">Auto biegt links auf dich zu — %1$.0f s</string>
<string name="usecase_narrative_smva_bcw_b">Schnell nahendes Fahrzeug — %1$.0f s</string>
<!-- Einstellungen > Use Case Alerts -->
<string name="settings_usecase_alerts">Use Case Alerts</string>
<string name="settings_usecase_alerts_desc">Ein-/Ausschalten pro Use Case für das CAM-basierte Use-Case-Alarm-Panel im V2X-Monitor. Alle Use Cases sind CAM-only — keiner löst ein DENM aus.</string>
<string name="settings_usecase_alert_levels_title">Alarmstufen-Schwellenwerte (nur lesend)</string>
<string name="settings_usecase_level_warning">Warning</string>
<string name="settings_usecase_level_awareness">Awareness</string>
<string name="settings_usecase_level_info">Info</string>
<string name="settings_usecase_ttc_le">≤ %1$.0f s bis Konflikt</string>
<!-- Dashboard transport --> <!-- Dashboard transport -->
<string name="dash_transport_usbc">USB-C</string> <string name="dash_transport_usbc">USB-C</string>
<string name="dash_transport_wifi">WLAN</string> <string name="dash_transport_wifi">WLAN</string>
+25 -3
View File
@@ -166,18 +166,40 @@
<string name="mqtt_no_topics_hint">Connect to the OBU and wait for V2X traffic</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> <string name="mqtt_no_messages">No messages on this topic yet</string>
<!-- DENM TX --> <!-- DENM TX — manual antenna/RSU-range test tool only, not use-case-driven -->
<string name="mqtt_last_tx">Last transmitted payload:</string> <string name="mqtt_last_tx">Last transmitted payload:</string>
<string name="mqtt_denm_tx_title">DENM Transmission</string> <string name="mqtt_denm_tx_title">DENM Transmission (Manual Test)</string>
<string name="mqtt_send_denm">Send Test DENM</string> <string name="mqtt_send_denm">Send Test DENM</string>
<string name="mqtt_stop_denm">Stop DENM</string> <string name="mqtt_stop_denm">Stop DENM</string>
<string name="mqtt_denm_use_case_desc">Aftermarket Stationary Vehicle (causeCode 94)</string> <string name="mqtt_denm_use_case_desc">Aftermarket Stationary Vehicle (causeCode 94)</string>
<string name="mqtt_denm_not_connected">Connect to the OBU to enable DENM triggering</string> <string name="mqtt_denm_not_connected">Connect to the OBU to enable DENM triggering</string>
<string name="mqtt_denm_active">DENM active — OBU broadcasting via ITS-G5</string> <string name="mqtt_denm_active">DENM active — OBU broadcasting via ITS-G5</string>
<string name="mqtt_denm_hint">Publishes uca-denmctrl (retained) to v2x-uca/input/denmtrg</string> <string name="mqtt_denm_hint">Manual antenna/RSU range test — publishes uca-denmctrl (retained) to v2x-uca/input/denmtrg. Not triggered by detected events or use case alerts.</string>
<string name="mqtt_denm_show">Show last TX</string> <string name="mqtt_denm_show">Show last TX</string>
<string name="mqtt_denm_hide">Hide</string> <string name="mqtt_denm_hide">Hide</string>
<!-- CAM-based Use Case Alert panel -->
<string name="mqtt_usecase_panel_title">CAM Use Case Alerts</string>
<string name="mqtt_usecase_not_connected">Connect to the OBU to evaluate CAM-based use cases</string>
<string name="mqtt_usecase_none_active">No active use case alerts</string>
<string name="mqtt_usecase_detail">Station %1$d · %2$.0f m · closing %3$.1f m/s · TTC %4$.1f s · %5$s</string>
<!-- Human-readable use-case narratives (Section 10.4 — not just raw JSON) -->
<string name="usecase_narrative_ima_b">Crossing vehicle ahead — %1$.0f s to conflict</string>
<string name="usecase_narrative_ima_s">Stopped vehicle may pull out — %1$.0f s</string>
<string name="usecase_narrative_rtw_b">Car turning right toward you — %1$.0f s</string>
<string name="usecase_narrative_ltw_b">Car turning left toward you — %1$.0f s</string>
<string name="usecase_narrative_smva_bcw_b">Fast-closing vehicle nearby — %1$.0f s</string>
<!-- Settings > Use Case Alerts -->
<string name="settings_usecase_alerts">Use Case Alerts</string>
<string name="settings_usecase_alerts_desc">Per-use-case enable/disable for the CAM-based Use Case Alert panel on the V2X Monitor screen. All use cases are CAM-only — none of them trigger a DENM.</string>
<string name="settings_usecase_alert_levels_title">Alert level thresholds (read-only)</string>
<string name="settings_usecase_level_warning">Warning</string>
<string name="settings_usecase_level_awareness">Awareness</string>
<string name="settings_usecase_level_info">Info</string>
<string name="settings_usecase_ttc_le">≤ %1$.0f s to conflict</string>
<!-- Dashboard transport --> <!-- Dashboard transport -->
<string name="dash_transport_usbc">USB-C</string> <string name="dash_transport_usbc">USB-C</string>
<string name="dash_transport_wifi">Wi-Fi</string> <string name="dash_transport_wifi">Wi-Fi</string>
+1 -1
View File
@@ -2,7 +2,7 @@
agp = "8.7.3" agp = "8.7.3"
kotlin = "2.1.0" kotlin = "2.1.0"
ksp = "2.1.0-1.0.29" ksp = "2.1.0-1.0.29"
hilt = "2.51.1" hilt = "2.56.2"
junit = "4.13.2" junit = "4.13.2"
hiltNavigationCompose = "1.2.0" hiltNavigationCompose = "1.2.0"
datastore = "1.1.1" datastore = "1.1.1"