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