diff --git a/V2X_MicrOBU_Android_App_Requirements_v9.docx b/V2X_MicrOBU_Android_App_Requirements_v9.docx deleted file mode 100644 index 1babb2c..0000000 Binary files a/V2X_MicrOBU_Android_App_Requirements_v9.docx and /dev/null differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 19837d2..b363613 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,8 @@ + + @@ -21,6 +23,10 @@ + + + diff --git a/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt b/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt index 5508c2b..13ff90c 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt @@ -1,8 +1,11 @@ package com.hawhamburg.micr0bu import android.Manifest +import android.content.Intent +import android.net.Uri import android.os.Build import android.os.Bundle +import android.provider.Settings import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.appcompat.app.AppCompatActivity 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.SettingsScreen 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.viewmodel.MqttViewModel import com.hawhamburg.micr0bu.viewmodel.SensorViewModel @@ -74,7 +78,10 @@ class MainActivity : AppCompatActivity() { val activeTransport by mqttViewModel.activeTransport.collectAsState() val usbConnected by mqttViewModel.usbConnected.collectAsState() val obuStationTypeWarning by mqttViewModel.obuStationTypeWarning.collectAsState() + val obuStationType by mqttViewModel.obuStationType.collectAsState() val tripServiceState by tripViewModel.serviceState.collectAsState() + val showBatteryOptPrompt by tripViewModel.showBatteryOptPrompt.collectAsState() + val useCaseEnabledMap by mqttViewModel.useCaseEnabledMap.collectAsState() MicrOBUTheme(darkTheme = state.darkTheme) { val view = LocalView.current @@ -128,6 +135,7 @@ class MainActivity : AppCompatActivity() { activeTransport = activeTransport, usbCableConnected = usbConnected, obuStationTypeWarning = obuStationTypeWarning, + obuStationType = obuStationType, onNavigateToConnection = { navController.navigate(Screen.Connection.route) }, onNavigateToSensors = { navController.navigate(Screen.Sensors.route) { @@ -147,6 +155,7 @@ class MainActivity : AppCompatActivity() { state = state, mqttConnectionState = mqttConnectionState, tripServiceState = tripServiceState, + showBatteryOptPrompt = showBatteryOptPrompt, onToggleRecording = { // Start / stop both CSV recording and the event detection service viewModel.toggleRecording() @@ -155,6 +164,15 @@ class MainActivity : AppCompatActivity() { onOpenSessionLog = { 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) { @@ -205,10 +223,18 @@ class MainActivity : AppCompatActivity() { onOpenUnits = { navController.navigate(Screen.SettingsUnits.route) }, onOpenConnection = { navController.navigate(Screen.SettingsConnection.route) }, onOpenMqtt = { navController.navigate(Screen.SettingsMqtt.route) }, + onOpenUseCaseAlerts = { navController.navigate(Screen.SettingsUseCaseAlerts.route) }, onOpenDeveloper = { navController.navigate(Screen.SettingsDeveloper.route) }, onOpenAbout = { navController.navigate(Screen.SettingsAbout.route) }, ) } + composable(Screen.SettingsUseCaseAlerts.route) { + UseCaseAlertsSettingsScreen( + enabledMap = useCaseEnabledMap, + onToggle = mqttViewModel::setUseCaseEnabled, + onBack = { navController.popBackStack() }, + ) + } composable(Screen.SettingsConnection.route) { ConnectionSettingsScreen( mqttPrefs = mqttPrefs, diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/CsvExporter.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/CsvExporter.kt index dab3130..0d40acd 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/data/CsvExporter.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/CsvExporter.kt @@ -2,8 +2,11 @@ package com.hawhamburg.micr0bu.data import android.content.Context import android.content.Intent +import android.net.Uri import androidx.core.content.FileProvider import com.hawhamburg.micr0bu.viewmodel.RecordingSession +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.File import java.text.SimpleDateFormat import java.util.Date @@ -17,28 +20,34 @@ fun sessionFileName(session: RecordingSession): String = .replace(" ", "_").replace(":", "-")}.csv" /** - * Returns the full CSV content for a session. - * 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. + * Shares the session CSV via Android's share sheet. + * + * 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 { - val dataFile = File(File(context.filesDir, "sessions"), "${session.id}.csv") - return if (dataFile.exists()) dataFile.readText() else buildSessionCsv(session) -} +suspend fun shareSessionCsv(context: Context, session: RecordingSession) { + val fileName = sessionFileName(session) + val dataFile = File(File(context.filesDir, "sessions"), "${session.id}.csv") + val cacheFile = File(context.cacheDir, fileName) -fun shareSessionCsv(context: Context, session: RecordingSession) { - val fileName = sessionFileName(session) - val content = readSessionCsvContent(context, session) - - // Write to cache so FileProvider can serve it via ACTION_SEND - val file = File(context.cacheDir, fileName).also { it.writeText(content) } + withContext(Dispatchers.IO) { + 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( context, "${context.packageName}.fileprovider", - file, + cacheFile, ) - val intent = Intent(Intent.ACTION_SEND).apply { type = "text/csv" putExtra(Intent.EXTRA_STREAM, uri) @@ -48,6 +57,25 @@ fun shareSessionCsv(context: Context, session: RecordingSession) { 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, * and by [readSessionCsvContent] when no data file is present on disk. diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/CamBuilder.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/CamBuilder.kt deleted file mode 100644 index f06f98c..0000000 --- a/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/CamBuilder.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttRepository.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttRepository.kt index 0a63766..8a7ca8c 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttRepository.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttRepository.kt @@ -1,9 +1,9 @@ package com.hawhamburg.micr0bu.data.mqtt -import android.content.Context import com.hawhamburg.micr0bu.data.transport.TransportType 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.Dispatchers import kotlinx.coroutines.Job @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlin.coroutines.resumeWithException import kotlinx.coroutines.suspendCancellableCoroutine @@ -34,6 +35,8 @@ import javax.inject.Inject import javax.inject.Singleton import org.eclipse.paho.client.mqttv3.MqttMessage as PahoMqttMessage +private const val MAX_MESSAGES_PER_TOPIC = 50 + private val SUBSCRIBED_TOPICS = listOf( "sys/state/heartbeat", "sys/state/cellular", @@ -47,7 +50,6 @@ private val SUBSCRIBED_TOPICS = listOf( @Singleton class MqttRepository @Inject constructor( - @ApplicationContext private val context: Context, private val prefs: MqttPreferences, private val usbDetector: UsbNetworkDetector, ) { @@ -64,6 +66,12 @@ class MqttRepository @Inject constructor( ) val messages: SharedFlow = _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>>(emptyMap()) + val topicMessages: StateFlow>> = _topicMessages.asStateFlow() + /** Active transport derived from persisted prefs. */ val activeTransport: StateFlow = prefs.prefsFlow .map { p -> @@ -75,6 +83,115 @@ class MqttRepository @Inject constructor( @Volatile private var activeClient: MqttAsyncClient? = 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 = _denmActive.asStateFlow() + + private val _lastDenmPayload = MutableStateFlow(null) + val lastDenmPayload: StateFlow = _lastDenmPayload.asStateFlow() + + private val _activeDenmUseCase = MutableStateFlow(null) + /** The use case id (e.g. "c2c-eebl") currently active on the OBU, if any. */ + val activeDenmUseCase: StateFlow = _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 ──────────────────────────────────────────────────────────── 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. * 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 messageArrived(topic: String, message: PahoMqttMessage) { - _messages.tryEmit( + recordMessage( MqttMessage( topic = topic, 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. // Without this, traffic would be routed over Wi-Fi even in USB-C mode. - if (usbActive) { - socketFactory = usbNet!!.socketFactory + if (isUsbC && usbNet != null) { + socketFactory = usbNet.socketFactory } } // Connect (bridge callback → coroutine) suspendCancellableCoroutine { cont -> 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) = cont.resumeWithException(ex) }) @@ -230,7 +331,7 @@ class MqttRepository @Inject constructor( val qosArr = IntArray(topicsArr.size) { 0 } suspendCancellableCoroutine { cont -> 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) = cont.resumeWithException(ex) }) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/EventDetector.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/EventDetector.kt index 871ffe0..3d49393 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/EventDetector.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/EventDetector.kt @@ -46,6 +46,7 @@ class EventDetector(private val config: DetectionConfig = DetectionConfig()) { private var turningStartTime = 0L private var turningBothAgreed = false private var peakGyroTurning = 0.0 + private var turningEventFired = false // ── Stopping state ──────────────────────────────────────────────────────── @@ -169,12 +170,16 @@ class EventDetector(private val config: DetectionConfig = DetectionConfig()) { turningStartTime = timestamp peakGyroTurning = 0.0 turningBothAgreed = false + turningEventFired = false } turningFrames++ peakGyroTurning = maxOf(peakGyroTurning, gyroMagnitude) 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 _events.tryEmit( @@ -190,10 +195,11 @@ class EventDetector(private val config: DetectionConfig = DetectionConfig()) { durationMs = timestamp - turningStartTime, ) ) - turningFrames = 0 + turningEventFired = true } } else { - turningFrames = 0 + turningFrames = 0 + turningEventFired = false } } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/service/TripRecordingService.kt b/app/src/main/java/com/hawhamburg/micr0bu/service/TripRecordingService.kt index d1025a6..8b3bc98 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/service/TripRecordingService.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/service/TripRecordingService.kt @@ -5,6 +5,7 @@ import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.Intent +import android.content.pm.ServiceInfo import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener @@ -12,6 +13,7 @@ import android.hardware.SensorManager import android.location.Location import android.os.IBinder import android.os.Looper +import android.os.PowerManager import androidx.core.app.NotificationCompat import com.google.android.gms.location.FusedLocationProviderClient 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.EventDetector import com.hawhamburg.micr0bu.domain.detection.EventType +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -44,7 +47,16 @@ import kotlin.math.sqrt * • Accelerometer + Gyroscope at [SensorManager.SENSOR_DELAY_GAME] (~50 Hz) * • FusedLocationProviderClient at 1 Hz * • 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() { // ── Constants ───────────────────────────────────────────────────────────── @@ -66,6 +78,11 @@ class TripRecordingService : Service() { private val serviceScope = CoroutineScope(Dispatchers.Default + serviceJob) 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 ────────────────────────────────────────────────────────── private lateinit var sensorManager: SensorManager @@ -207,6 +224,14 @@ class TripRecordingService : Service() { // ── Trip control ───────────────────────────────────────────────────────── 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() brakingCount = 0 turningCount = 0 @@ -272,11 +297,21 @@ class TripRecordingService : Service() { registerSensors() requestLocationUpdates() - // Start foreground ASAP (within 5 s required by Android) - startForeground(NOTIFICATION_ID, buildNotification(0L, 0)) + // Start foreground ASAP (within 5 s required by Android). + // 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() { + wakeLock?.let { if (it.isHeld) it.release() } + wakeLock = null timerJob?.cancel() unregisterSensors() removeLocationUpdates() @@ -377,7 +412,7 @@ class TripRecordingService : Service() { .createNotificationChannel(channel) } - private fun buildNotification(elapsedSeconds: Long, eventCount: Int) = + private fun buildNotification(elapsedSeconds: Long) = NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("Recording trip") .setContentText( @@ -390,17 +425,22 @@ class TripRecordingService : Service() { .setContentIntent( PendingIntent.getActivity( this, 0, - Intent(this, MainActivity::class.java), - PendingIntent.FLAG_IMMUTABLE, + Intent(this, MainActivity::class.java).apply { + // 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() private fun updateNotification() { val elapsed = TripServiceBus.state.value.elapsedSeconds - val total = brakingCount + turningCount + stoppingCount 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 { diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt index f2b7201..d86afef 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt @@ -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 SettingsConnection : Screen("settings/connection", R.string.settings_connection) 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 SettingsAbout : Screen("settings/about", R.string.settings_about) } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/DashboardScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/DashboardScreen.kt index 57468ba..d0c8525 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/DashboardScreen.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/DashboardScreen.kt @@ -61,6 +61,7 @@ fun DashboardScreen( activeTransport: TransportType = TransportType.USB_C, usbCableConnected: Boolean = false, obuStationTypeWarning: Boolean = false, + obuStationType: Int? = null, onNavigateToConnection: () -> Unit, onNavigateToSensors: () -> Unit, onNavigateToMap: () -> Unit, @@ -195,7 +196,10 @@ fun DashboardScreen( modifier = Modifier.size(16.dp), ) 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, color = Color(0xFFFFC107), ) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt index 294b3a8..cae6f7c 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt @@ -65,6 +65,11 @@ import com.hawhamburg.micr0bu.R import com.hawhamburg.micr0bu.data.mqtt.MessageDirection import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState 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 org.json.JSONArray import org.json.JSONException @@ -86,6 +91,12 @@ private val DisconnectedGrayBg = Color(0xFF1C2733) private val DenmRed = Color(0xFFE53935) private val DenmRedBg = Color(0xFF3A0A0A) 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 fun MqttTopicViewerScreen( @@ -97,6 +108,9 @@ fun MqttTopicViewerScreen( val autoScroll by viewModel.autoScroll.collectAsState() val denmActive by viewModel.denmActive.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 val sortedTopics = topicMessages.keys.sortedWith( @@ -172,14 +186,17 @@ fun MqttTopicViewerScreen( connectionState = connectionState, denmActive = denmActive, lastDenmPayload = lastDenmPayload, + activeDenmUseCase = activeDenmUseCase, + useCaseAlerts = useCaseAlerts, onSelectTopic = { viewModel.selectTopic(it) }, onSendDenm = { viewModel.sendDenm() }, onStopDenm = { viewModel.stopDenm() }, ) } else { MessageDetailPane( - messages = topicMessages[selectedTopic] ?: emptyList(), - autoScroll = autoScroll, + messages = topicMessages[selectedTopic] ?: emptyList(), + autoScroll = autoScroll, + ownStationId = ownStationId, ) } } @@ -194,6 +211,8 @@ private fun TopicListPane( connectionState: MqttConnectionState, denmActive: Boolean, lastDenmPayload: String?, + activeDenmUseCase: String?, + useCaseAlerts: List, onSelectTopic: (String) -> Unit, onSendDenm: () -> Unit, onStopDenm: () -> Unit, @@ -202,11 +221,17 @@ private fun TopicListPane( 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( isConnected = isConnected, denmActive = denmActive, lastDenmPayload = lastDenmPayload, + activeDenmUseCase = activeDenmUseCase, onSendDenm = onSendDenm, onStopDenm = onStopDenm, ) @@ -310,6 +335,7 @@ private fun TopicRow( private fun MessageDetailPane( messages: List, autoScroll: Boolean, + ownStationId: Long?, ) { if (messages.isEmpty()) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -331,18 +357,29 @@ private fun MessageDetailPane( LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) { 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)) } } } @Composable -private fun MessageRow(msg: MqttMessage) { - // Show TX badge for all outbound messages; RX badge only on DENM topics - // (to avoid noisy badges on high-frequency CAM/SPAT traffic). - val showBadge = msg.direction == MessageDirection.TX || +private fun MessageRow(msg: MqttMessage, ownStationId: Long?) { + // CAM messages get an OWN/REMOTE badge instead of a TX/RX badge — that's the + // distinction the V2X Monitor and Use Case Alert panel actually care about (Section 10.4). + 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)) + ) Column( modifier = Modifier @@ -359,7 +396,8 @@ private fun MessageRow(msg: MqttMessage) { color = MaterialTheme.colorScheme.onSurfaceVariant, 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)) Box( @@ -389,6 +427,7 @@ private fun DenmTxCard( isConnected: Boolean, denmActive: Boolean, lastDenmPayload: String?, + activeDenmUseCase: String?, onSendDenm: () -> Unit, onStopDenm: () -> Unit, ) { @@ -450,7 +489,7 @@ private fun DenmTxCard( shape = RoundedCornerShape(4.dp), ) { Text( - "hln-sv", + activeDenmUseCase ?: DenmUseCase.STATIONARY.id, style = MaterialTheme.typography.labelSmall, color = if (denmActive) DenmRed 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, 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 ──────────────────────────────────────────────────── @Composable diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/RecordingScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/RecordingScreen.kt index 56463e1..2546163 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/RecordingScreen.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/RecordingScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.FiberManualRecord import androidx.compose.material.icons.filled.TableChart import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.OutlinedButton import androidx.compose.material3.ButtonDefaults @@ -25,6 +26,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -44,10 +46,33 @@ fun RecordingScreen( state: SensorUiState, mqttConnectionState: MqttConnectionState, tripServiceState: TripServiceBus.State, + showBatteryOptPrompt: Boolean, onToggleRecording: () -> Unit, onOpenSessionLog: () -> Unit, + onOpenBatterySettings: () -> Unit, + onDismissBatteryOptPrompt: () -> Unit, 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( modifier = modifier .fillMaxSize() diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SessionLogScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SessionLogScreen.kt index 18403a1..e768eba 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SessionLogScreen.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SessionLogScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment 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.unit.dp 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.shareSessionCsv +import kotlinx.coroutines.launch import com.hawhamburg.micr0bu.viewmodel.RecordingSession import com.hawhamburg.micr0bu.viewmodel.SensorUiState import java.text.SimpleDateFormat @@ -56,6 +58,7 @@ fun SessionLogScreen( modifier: Modifier = Modifier, ) { val context = LocalContext.current + val scope = rememberCoroutineScope() var pendingDelete by remember { mutableStateOf(null) } var pendingSave by remember { mutableStateOf(null) } @@ -64,8 +67,8 @@ fun SessionLogScreen( ) { uri -> uri ?: return@rememberLauncherForActivityResult val session = pendingSave ?: return@rememberLauncherForActivityResult - context.contentResolver.openOutputStream(uri)?.use { it.write(readSessionCsvContent(context, session).toByteArray(Charsets.UTF_8)) } pendingSave = null + scope.launch { saveSessionCsvToUri(context, session, uri) } } pendingDelete?.let { session -> @@ -103,7 +106,7 @@ fun SessionLogScreen( SessionCard( session = session, index = state.sessions.size - index, - onShare = { shareSessionCsv(context, session) }, + onShare = { scope.launch { shareSessionCsv(context, session) } }, onSaveToDevice = { pendingSave = session; saveLauncher.launch(sessionFileName(session)) }, onDelete = { pendingDelete = session }, ) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SettingsScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SettingsScreen.kt index 84fc73f..7952d4a 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SettingsScreen.kt @@ -47,6 +47,8 @@ import androidx.compose.ui.unit.dp import androidx.core.os.LocaleListCompat import com.hawhamburg.micr0bu.R 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 private data class LangOption(val tag: String, val flag: String, val labelRes: Int) @@ -66,6 +68,7 @@ fun SettingsScreen( onOpenUnits: () -> Unit, onOpenConnection: () -> Unit, onOpenMqtt: () -> Unit, + onOpenUseCaseAlerts: () -> Unit, onOpenDeveloper: () -> Unit, onOpenAbout: () -> Unit, modifier: Modifier = Modifier, @@ -99,6 +102,8 @@ fun SettingsScreen( RowDivider() MenuRow(stringResource(R.string.settings_mqtt_section), onOpenMqtt) RowDivider() + MenuRow(stringResource(R.string.settings_usecase_alerts), onOpenUseCaseAlerts) + RowDivider() MenuRow(stringResource(R.string.settings_developer), onOpenDeveloper) RowDivider() MenuRow(stringResource(R.string.settings_about), onOpenAbout) @@ -284,7 +289,57 @@ fun MqttSettingsScreen( ) { SubScreen(stringResource(R.string.settings_mqtt_section), onBack) { SectionCard { - MqttSettingsSection(prefs = mqttPrefs, onChange = onMqttPrefsChange) + MqttSettingsSection(prefs = mqttPrefs, onChange = onMqttPrefsChange, onSave = onBack) + } + } +} + +@Composable +fun UseCaseAlertsSettingsScreen( + enabledMap: Map, + 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 -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 // external save (e.g. via the Save button), while preserving in-progress edits. var ip by remember(prefs.brokerIp) { mutableStateOf(prefs.brokerIp) } @@ -401,6 +460,7 @@ private fun MqttSettingsSection(prefs: MqttPrefs, onChange: (MqttPrefs) -> Unit) password = password, ) ) + onSave() }, modifier = Modifier.fillMaxWidth(), ) { diff --git a/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt index 8fc1feb..badfff5 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt @@ -2,7 +2,7 @@ package com.hawhamburg.micr0bu.viewmodel import androidx.lifecycle.ViewModel 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.MqttMessage 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.transport.TransportType 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 kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -17,29 +20,23 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.json.JSONObject 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 class MqttViewModel @Inject constructor( private val repo: MqttRepository, private val prefs: MqttPreferences, private val usbDetector: UsbNetworkDetector, + private val camUseCaseRepository: CamUseCaseRepository, ) : ViewModel() { // ── MQTT connection & messages ──────────────────────────────────────────── val connectionState: StateFlow = repo.connectionState - private val _topicMessages = MutableStateFlow>>(emptyMap()) - val topicMessages: StateFlow>> = _topicMessages.asStateFlow() + val topicMessages: StateFlow>> = repo.topicMessages private val _selectedTopic = MutableStateFlow(null) val selectedTopic: StateFlow = _selectedTopic.asStateFlow() @@ -69,11 +66,16 @@ class MqttViewModel @Inject constructor( // ── OBU identity (parsed from v2x/rx/obu_gnss own_info) ────────────────── - private val _obuStationType = MutableStateFlow(null) /** * 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. */ + private val _obuStationType = MutableStateFlow(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 = _obuStationType.asStateFlow() /** @@ -87,30 +89,42 @@ class MqttViewModel @Inject constructor( // ── DENM transmission ───────────────────────────────────────────────────── - private val _denmActive = MutableStateFlow(false) - /** True while the DENM use case is actively broadcasting on the OBU. */ - val denmActive: StateFlow = _denmActive.asStateFlow() + /** True while a DENM use case is actively broadcasting on the OBU. */ + val denmActive: StateFlow = repo.denmActive - private val _lastDenmPayload = MutableStateFlow(null) /** JSON string of the most recently transmitted DENM control message. */ - val lastDenmPayload: StateFlow = _lastDenmPayload.asStateFlow() + val lastDenmPayload: StateFlow = repo.lastDenmPayload - /** Tracks which use case is currently active so Stop uses the same usecase field. */ - private var activeDenmUseCase: String? = null + /** The use case id currently active on the OBU, if any (e.g. for showing in the UI). */ + val activeDenmUseCase: StateFlow = 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 = camUseCaseRepository.ownStationId + + /** Active CAM-based use case alerts, filtered to the use cases enabled in Settings. */ + val useCaseAlerts: StateFlow> = camUseCaseRepository.enabledAlerts + + /** Per-use-case enable/disable state (Settings > Use Case Alerts). */ + val useCaseEnabledMap: StateFlow> = 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 { 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 -> - // 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") { runCatching { 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 - * 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) { - if (_denmActive.value) return // prevent concurrent activations - 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) + fun sendDenm(useCase: String = DenmUseCase.STATIONARY.id) { + repo.activateDenm(useCase) } /** - * Publish a uca-denmctrl deactivate message (retained). - * Uses the same usecase string as the activate call so the OBU phases out the correct DENM. + * Publish a uca-denmctrl deactivate message (retained) for whichever use case is + * currently active. */ fun stopDenm() { - if (!_denmActive.value) return - 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) - } + repo.deactivateDenm() } // ── Lifecycle ───────────────────────────────────────────────────────────── diff --git a/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/TripRecordingViewModel.kt b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/TripRecordingViewModel.kt index 0152888..59ddea8 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/TripRecordingViewModel.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/TripRecordingViewModel.kt @@ -1,7 +1,9 @@ package com.hawhamburg.micr0bu.viewmodel import android.app.Application +import android.content.Context import android.content.Intent +import android.os.PowerManager import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.hawhamburg.micr0bu.data.TripRepository @@ -27,6 +29,32 @@ class TripRecordingViewModel(application: Application) : AndroidViewModel(applic 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 = _showBatteryOptPrompt.asStateFlow() + + private fun shouldShowBatteryPrompt(): Boolean = + !pm.isIgnoringBatteryOptimizations(getApplication().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) ─────────────────────────────── /** Live recording state forwarded from [TripServiceBus]. */ diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 17d616c..a68aeca 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -165,18 +165,40 @@ Mit der OBU verbinden und auf V2X-Verkehr warten Noch keine Nachrichten zu diesem Thema - + Zuletzt gesendete Nutzlast: - DENM-Übertragung + DENM-Übertragung (manueller Test) Test-DENM senden DENM stoppen Liegengebliebenes Fahrzeug (causeCode 94) Mit OBU verbinden, um DENM-Auslösung zu aktivieren DENM aktiv — OBU sendet über ITS-G5 - Sendet uca-denmctrl (retained) an v2x-uca/input/denmtrg + Manueller Antennen-/RSU-Reichweitentest — sendet uca-denmctrl (retained) an v2x-uca/input/denmtrg. Wird nicht durch erkannte Ereignisse oder Use-Case-Alarme ausgelöst. Letztes TX anzeigen Ausblenden + + CAM-Use-Case-Alarme + Mit OBU verbinden, um CAM-basierte Use Cases auszuwerten + Keine aktiven Use-Case-Alarme + Station %1$d · %2$.0f m · Annäherung %3$.1f m/s · TTC %4$.1f s · %5$s + + + Kreuzendes Fahrzeug — %1$.0f s bis Konflikt + Stehendes Fahrzeug könnte losfahren — %1$.0f s + Auto biegt rechts auf dich zu — %1$.0f s + Auto biegt links auf dich zu — %1$.0f s + Schnell nahendes Fahrzeug — %1$.0f s + + + Use Case Alerts + 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. + Alarmstufen-Schwellenwerte (nur lesend) + Warning + Awareness + Info + ≤ %1$.0f s bis Konflikt + USB-C WLAN diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d85e526..9d7d671 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -166,18 +166,40 @@ Connect to the OBU and wait for V2X traffic No messages on this topic yet - + Last transmitted payload: - DENM Transmission + DENM Transmission (Manual Test) Send Test DENM Stop DENM Aftermarket Stationary Vehicle (causeCode 94) Connect to the OBU to enable DENM triggering DENM active — OBU broadcasting via ITS-G5 - Publishes uca-denmctrl (retained) to v2x-uca/input/denmtrg + Manual antenna/RSU range test — publishes uca-denmctrl (retained) to v2x-uca/input/denmtrg. Not triggered by detected events or use case alerts. Show last TX Hide + + CAM Use Case Alerts + Connect to the OBU to evaluate CAM-based use cases + No active use case alerts + Station %1$d · %2$.0f m · closing %3$.1f m/s · TTC %4$.1f s · %5$s + + + Crossing vehicle ahead — %1$.0f s to conflict + Stopped vehicle may pull out — %1$.0f s + Car turning right toward you — %1$.0f s + Car turning left toward you — %1$.0f s + Fast-closing vehicle nearby — %1$.0f s + + + Use Case Alerts + 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. + Alert level thresholds (read-only) + Warning + Awareness + Info + ≤ %1$.0f s to conflict + USB-C Wi-Fi diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 35855cc..825eb1b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ agp = "8.7.3" kotlin = "2.1.0" ksp = "2.1.0-1.0.29" -hilt = "2.51.1" +hilt = "2.56.2" junit = "4.13.2" hiltNavigationCompose = "1.2.0" datastore = "1.1.1"