Initial Commit

This commit is contained in:
Ashin Walpola
2026-06-03 14:51:31 +02:00
parent ee122765e1
commit ae17d4fcba
74 changed files with 7126 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Networking (MQTT / tile downloads) -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<!-- Location -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Phase A: foreground service (trip recording) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SENSOR" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<!-- Notification permission required on Android 13+ -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Keep CPU active while recording with screen off -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<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.compass" android:required="false" />
<uses-feature android:name="android.hardware.sensor.barometer" android:required="false" />
<application
android:name=".MicrOBUApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="MicrOBU"
android:theme="@style/Theme.MicrOBU"
android:supportsRtl="true"
android:localeConfig="@xml/locales_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.MicrOBU.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Phase A: Trip Recording foreground service.
foregroundServiceType="location" covers the GPS requirement.
Sensor access (accelerometer / gyroscope) does not have its own
foregroundServiceType flag; FOREGROUND_SERVICE_SENSOR permission
in the <uses-permission> block is sufficient for API 34+. -->
<service
android:name=".service.TripRecordingService"
android:foregroundServiceType="location"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

@@ -0,0 +1,275 @@
package com.hawhamburg.micr0bu
import android.Manifest
import android.os.Build
import android.os.Bundle
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.appcompat.app.AppCompatActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.hawhamburg.micr0bu.ui.components.StatusTopBar
import com.hawhamburg.micr0bu.ui.navigation.BottomNavBar
import com.hawhamburg.micr0bu.ui.navigation.Screen
import com.hawhamburg.micr0bu.ui.screens.ConnectionSetupScreen
import com.hawhamburg.micr0bu.ui.screens.DashboardScreen
import com.hawhamburg.micr0bu.ui.screens.MqttTopicViewerScreen
import com.hawhamburg.micr0bu.ui.screens.RecordingScreen
import com.hawhamburg.micr0bu.ui.screens.SensorScreen
import com.hawhamburg.micr0bu.ui.screens.SessionLogScreen
import com.hawhamburg.micr0bu.ui.screens.MapScreen
import com.hawhamburg.micr0bu.ui.screens.TripHistoryScreen
import com.hawhamburg.micr0bu.ui.screens.TripReviewScreen
import com.hawhamburg.micr0bu.ui.screens.AboutSettingsScreen
import com.hawhamburg.micr0bu.ui.screens.AppearanceSettingsScreen
import com.hawhamburg.micr0bu.ui.screens.ConnectionSettingsScreen
import com.hawhamburg.micr0bu.ui.screens.DeveloperSettingsScreen
import com.hawhamburg.micr0bu.ui.screens.LanguageSettingsScreen
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.theme.MicrOBUTheme
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
import com.hawhamburg.micr0bu.viewmodel.SensorViewModel
import com.hawhamburg.micr0bu.viewmodel.TripRecordingViewModel
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private val viewModel: SensorViewModel by viewModels()
private val mqttViewModel: MqttViewModel by viewModels()
private val tripViewModel: TripRecordingViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
)
setContent {
val state by viewModel.state.collectAsState()
val mqttPrefs by mqttViewModel.mqttPrefs.collectAsState()
val mqttConnectionState by mqttViewModel.connectionState.collectAsState()
val activeTransport by mqttViewModel.activeTransport.collectAsState()
val usbConnected by mqttViewModel.usbConnected.collectAsState()
val obuStationTypeWarning by mqttViewModel.obuStationTypeWarning.collectAsState()
val tripServiceState by tripViewModel.serviceState.collectAsState()
MicrOBUTheme(darkTheme = state.darkTheme) {
val view = LocalView.current
SideEffect {
WindowCompat.getInsetsController(window, view).apply {
isAppearanceLightStatusBars = !state.darkTheme
isAppearanceLightNavigationBars = !state.darkTheme
}
}
val navController = rememberNavController()
val locationLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val granted = permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true
if (granted) viewModel.onLocationPermissionGranted()
}
// Request POST_NOTIFICATIONS on Android 13+
val notificationLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { /* ignored — notification permission is best-effort */ }
LaunchedEffect(Unit) {
viewModel.startImuStreams()
locationLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
)
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
notificationLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
Scaffold(
topBar = { StatusTopBar(state, mqttConnectionState) },
bottomBar = { BottomNavBar(navController) },
) { innerPadding ->
NavHost(
navController = navController,
startDestination = Screen.Dashboard.route,
modifier = Modifier.padding(innerPadding),
) {
composable(Screen.Dashboard.route) {
DashboardScreen(
state = state,
mqttConnectionState = mqttConnectionState,
activeTransport = activeTransport,
usbCableConnected = usbConnected,
obuStationTypeWarning = obuStationTypeWarning,
onNavigateToConnection = { navController.navigate(Screen.Connection.route) },
onNavigateToSensors = {
navController.navigate(Screen.Sensors.route) {
popUpTo(Screen.Dashboard.route) { saveState = true }
launchSingleTop = true
restoreState = true
}
},
onNavigateToMap = { navController.navigate(Screen.Map.route) },
)
}
composable(Screen.Sensors.route) {
SensorScreen(state = state)
}
composable(Screen.Record.route) {
RecordingScreen(
state = state,
mqttConnectionState = mqttConnectionState,
tripServiceState = tripServiceState,
onToggleRecording = {
// Start / stop both CSV recording and the event detection service
viewModel.toggleRecording()
tripViewModel.toggleRecording()
},
onOpenSessionLog = {
navController.navigate(Screen.Log.route)
},
)
}
composable(Screen.Log.route) {
SessionLogScreen(
state = state,
onDeleteSession = viewModel::deleteSession,
)
}
// ── Phase A: Trip History ─────────────────────────────
composable(Screen.TripHistory.route) {
TripHistoryScreen(
viewModel = tripViewModel,
onOpenTrip = { tripId ->
navController.navigate(Screen.TripReview.route(tripId))
},
)
}
composable(
route = Screen.TripReview.route,
arguments = listOf(
navArgument("tripId") { type = NavType.LongType }
),
) { backStackEntry ->
val tripId = backStackEntry.arguments
?.getLong("tripId", -1L) ?: -1L
if (tripId < 0L) return@composable
val trips by tripViewModel.trips.collectAsState(initial = emptyList())
val trip = trips.firstOrNull { it.id == tripId }
if (trip != null) {
TripReviewScreen(
trip = trip,
viewModel = tripViewModel,
)
}
}
composable(Screen.MqttViewer.route) {
MqttTopicViewerScreen(viewModel = mqttViewModel)
}
composable(Screen.Settings.route) {
SettingsScreen(
onOpenLanguage = { navController.navigate(Screen.SettingsLanguage.route) },
onOpenAppearance = { navController.navigate(Screen.SettingsAppearance.route) },
onOpenSensors = { navController.navigate(Screen.SettingsSensors.route) },
onOpenUnits = { navController.navigate(Screen.SettingsUnits.route) },
onOpenConnection = { navController.navigate(Screen.SettingsConnection.route) },
onOpenMqtt = { navController.navigate(Screen.SettingsMqtt.route) },
onOpenDeveloper = { navController.navigate(Screen.SettingsDeveloper.route) },
onOpenAbout = { navController.navigate(Screen.SettingsAbout.route) },
)
}
composable(Screen.SettingsConnection.route) {
ConnectionSettingsScreen(
mqttPrefs = mqttPrefs,
onMqttPrefsChange = mqttViewModel::updatePrefs,
onBack = { navController.popBackStack() },
)
}
composable(Screen.SettingsLanguage.route) {
LanguageSettingsScreen(onBack = { navController.popBackStack() })
}
composable(Screen.SettingsAppearance.route) {
AppearanceSettingsScreen(
darkTheme = state.darkTheme,
onDarkTheme = viewModel::setDarkTheme,
onBack = { navController.popBackStack() },
)
}
composable(Screen.SettingsSensors.route) {
SensorsSettingsScreen(
state = state,
onAccelEnabled = viewModel::setAccelEnabled,
onGyroEnabled = viewModel::setGyroEnabled,
onMagnetEnabled = viewModel::setMagnetEnabled,
onBarometerEnabled = viewModel::setBarometerEnabled,
onGnssEnabled = viewModel::setGnssEnabled,
onBack = { navController.popBackStack() },
)
}
composable(Screen.SettingsUnits.route) {
UnitsSettingsScreen(
useMetric = state.useMetric,
onUseMetric = viewModel::setUseMetric,
onBack = { navController.popBackStack() },
)
}
composable(Screen.SettingsMqtt.route) {
MqttSettingsScreen(
mqttPrefs = mqttPrefs,
onMqttPrefsChange = mqttViewModel::updatePrefs,
onBack = { navController.popBackStack() },
)
}
composable(Screen.SettingsDeveloper.route) {
DeveloperSettingsScreen(
state = state,
onDeveloperMode = viewModel::setDeveloperMode,
onBack = { navController.popBackStack() },
)
}
composable(Screen.SettingsAbout.route) {
AboutSettingsScreen(onBack = { navController.popBackStack() })
}
composable(Screen.Connection.route) {
ConnectionSetupScreen()
}
composable(Screen.Map.route) {
MapScreen(gnss = state.gnss)
}
}
}
}
}
}
}
@@ -0,0 +1,30 @@
package com.hawhamburg.micr0bu
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class MicrOBUApplication : Application() {
override fun onCreate() {
super.onCreate()
createNotificationChannels()
}
private fun createNotificationChannels() {
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
// Phase A: trip recording foreground service channel
nm.createNotificationChannel(
NotificationChannel(
"trip_recording",
"Trip Recording",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Shown while a bike trip is being recorded"
}
)
}
}
@@ -0,0 +1,71 @@
package com.hawhamburg.micr0bu.data
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import com.hawhamburg.micr0bu.viewmodel.RecordingSession
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val iso = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
private val human = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
fun sessionFileName(session: RecordingSession): String =
"micr0bu_session_${human.format(Date(session.startTime))
.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.
*/
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)
}
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) }
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file,
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/csv"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, "MicrOBU Session Export — $fileName")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "Export session"))
}
/**
* 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.
*/
fun buildSessionCsv(s: RecordingSession): String = buildString {
appendLine("# MicrOBU Session Export")
appendLine("# Generated by MicrOBU v0.2.0 — HAW Hamburg / Project MicrOBU")
appendLine("# Session ID,${s.id}")
appendLine("# Start,${iso.format(Date(s.startTime))}")
appendLine("# End,${iso.format(Date(s.endTime))}")
appendLine("# Duration (s),${s.durationSeconds}")
appendLine("# Samples captured,${s.sampleCount}")
appendLine("# NOTE: This session was recorded before stream-to-file logging was enabled.")
appendLine()
appendLine("field,value,unit,timestamp_iso")
appendLine("session_id,${s.id},,${iso.format(Date(s.startTime))}")
appendLine("start_time,${s.startTime},ms_epoch,${iso.format(Date(s.startTime))}")
appendLine("end_time,${s.endTime},ms_epoch,${iso.format(Date(s.endTime))}")
appendLine("duration,${s.durationSeconds},s,${iso.format(Date(s.endTime))}")
appendLine("sample_count,${s.sampleCount},samples,${iso.format(Date(s.endTime))}")
}
@@ -0,0 +1,104 @@
package com.hawhamburg.micr0bu.data
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.location.Location
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
data class ImuReading(
val x: Float,
val y: Float,
val z: Float,
val timestamp: Long = System.currentTimeMillis()
)
data class GnssReading(
val latitude: Double,
val longitude: Double,
val altitude: Double,
val speedMs: Float,
val bearingDeg: Float,
val accuracyM: Float,
val timestamp: Long = System.currentTimeMillis()
)
class SensorRepository(private val context: Context) {
private val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
private val fusedLocation = LocationServices.getFusedLocationProviderClient(context)
fun accelerometerFlow(): Flow<ImuReading> = sensorFlow(Sensor.TYPE_ACCELEROMETER)
fun gyroscopeFlow(): Flow<ImuReading> = sensorFlow(Sensor.TYPE_GYROSCOPE)
fun magnetometerFlow(): Flow<ImuReading> = sensorFlow(Sensor.TYPE_MAGNETIC_FIELD)
fun barometerFlow(): Flow<Float> = callbackFlow {
val sensor = sensorManager.getDefaultSensor(Sensor.TYPE_PRESSURE)
if (sensor == null) {
close()
return@callbackFlow
}
val listener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
trySend(event.values[0])
}
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
}
sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
awaitClose { sensorManager.unregisterListener(listener) }
}
@Suppress("MissingPermission")
fun gnssFlow(): Flow<GnssReading> = callbackFlow {
val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000L)
.setMinUpdateIntervalMillis(500L)
.build()
val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
result.lastLocation?.let { loc ->
trySend(loc.toReading())
}
}
}
fusedLocation.requestLocationUpdates(request, callback, context.mainLooper)
awaitClose { fusedLocation.removeLocationUpdates(callback) }
}
fun hasSensor(type: Int): Boolean = sensorManager.getDefaultSensor(type) != null
private fun sensorFlow(type: Int): Flow<ImuReading> = callbackFlow {
val sensor = sensorManager.getDefaultSensor(type)
if (sensor == null) {
close()
return@callbackFlow
}
val listener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
trySend(ImuReading(event.values[0], event.values[1], event.values[2]))
}
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
}
sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_UI)
awaitClose { sensorManager.unregisterListener(listener) }
}
}
private fun Location.toReading() = GnssReading(
latitude = latitude,
longitude = longitude,
altitude = altitude,
speedMs = speed,
bearingDeg = bearing,
accuracyM = accuracy
)
@@ -0,0 +1,93 @@
package com.hawhamburg.micr0bu.data
import com.hawhamburg.micr0bu.data.db.AppDatabase
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
import com.hawhamburg.micr0bu.domain.detection.DetectedEvent
import kotlinx.coroutines.flow.Flow
/**
* Repository that abstracts Room access for trips and detected events.
*
* All suspend functions are safe to call from a coroutine running on any
* dispatcher; Room executes the actual SQL on its own I/O thread pool.
*/
class TripRepository(db: AppDatabase) {
private val dao = db.tripDao()
// ── Trips ─────────────────────────────────────────────────────────────────
/**
* Inserts a new trip row and returns the auto-generated [RecordedTripEntity.id].
* Call this at the start of a recording session.
*/
suspend fun insertTrip(
startTime: Long,
endTime: Long = startTime,
distanceMetres: Float = 0f,
eventCount: Int = 0,
gpsTrackJson: String = "[]",
): Long = dao.insertTrip(
RecordedTripEntity(
startTime = startTime,
endTime = endTime,
distanceMetres = distanceMetres,
eventCount = eventCount,
gpsTrackJson = gpsTrackJson,
)
)
/**
* Finalises a trip row with duration, distance, event count, and the GPS
* polyline. Call this when the user stops recording.
*/
suspend fun finaliseTrip(
tripId: Long,
endTime: Long,
distanceMetres: Float,
eventCount: Int,
gpsTrackJson: String,
) {
val existing = dao.getTripById(tripId) ?: return
dao.updateTrip(
existing.copy(
endTime = endTime,
distanceMetres = distanceMetres,
eventCount = eventCount,
gpsTrackJson = gpsTrackJson,
)
)
}
/** Emits all trips ordered by startTime DESC, updating whenever the DB changes. */
fun getAllTrips(): Flow<List<RecordedTripEntity>> = dao.getAllTrips()
suspend fun deleteTrip(tripId: Long) = dao.deleteTripById(tripId)
// ── Events ────────────────────────────────────────────────────────────────
/**
* Persists a domain [DetectedEvent] for the given [tripId].
* Converts the domain model to the Room entity.
*/
suspend fun insertEvent(tripId: Long, event: DetectedEvent) =
dao.insertEvent(
DetectedEventEntity(
tripId = tripId,
timestamp = event.timestamp,
type = event.type.name,
confidence = event.confidence.name,
latitude = event.latitude,
longitude = event.longitude,
speedMps = event.speedMps.toFloat(),
peakAccelMagnitude = event.peakAccelMagnitude.toFloat(),
peakGyroMagnitude = event.peakGyroMagnitude.toFloat(),
durationMs = event.durationMs,
)
)
/** Emits events for [tripId] ordered by timestamp, updating whenever the DB changes. */
fun getEventsForTrip(tripId: Long): Flow<List<DetectedEventEntity>> =
dao.getEventsForTrip(tripId)
}
@@ -0,0 +1,87 @@
package com.hawhamburg.micr0bu.data.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Database(
entities = [
SessionEntity::class,
RecordedTripEntity::class,
DetectedEventEntity::class,
],
version = 2,
exportSchema = false,
)
abstract class AppDatabase : RoomDatabase() {
abstract fun sessionDao(): SessionDao
abstract fun tripDao(): TripDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase =
INSTANCE ?: synchronized(this) {
Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"micr0bu.db",
)
.addMigrations(MIGRATION_1_2)
.build()
.also { INSTANCE = it }
}
// ── Migrations ────────────────────────────────────────────────────────
/**
* Adds the `trips` and `detected_events` tables introduced in Phase A.
* The existing `sessions` table is left untouched.
*/
private val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `trips` (
`id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
`startTime` INTEGER NOT NULL,
`endTime` INTEGER NOT NULL,
`distanceMetres` REAL NOT NULL,
`eventCount` INTEGER NOT NULL,
`gpsTrackJson` TEXT NOT NULL
)
""".trimIndent()
)
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `detected_events` (
`id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
`tripId` INTEGER NOT NULL,
`timestamp` INTEGER NOT NULL,
`type` TEXT NOT NULL,
`confidence` TEXT NOT NULL,
`latitude` REAL NOT NULL,
`longitude` REAL NOT NULL,
`speedMps` REAL NOT NULL,
`peakAccelMagnitude` REAL NOT NULL,
`peakGyroMagnitude` REAL NOT NULL,
`durationMs` INTEGER NOT NULL,
FOREIGN KEY(`tripId`) REFERENCES `trips`(`id`)
ON UPDATE NO ACTION ON DELETE CASCADE
)
""".trimIndent()
)
database.execSQL(
"CREATE INDEX IF NOT EXISTS `index_detected_events_tripId` " +
"ON `detected_events` (`tripId`)"
)
}
}
}
}
@@ -0,0 +1,50 @@
package com.hawhamburg.micr0bu.data.db
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
/**
* One detected cyclist event (braking / turning / stopping) linked to a
* [RecordedTripEntity] via the [tripId] foreign key.
*
* [type] and [confidence] are stored as the enum name strings so that the
* database remains human-readable.
*/
@Entity(
tableName = "detected_events",
foreignKeys = [
ForeignKey(
entity = RecordedTripEntity::class,
parentColumns = ["id"],
childColumns = ["tripId"],
onDelete = ForeignKey.CASCADE,
)
],
)
data class DetectedEventEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
@ColumnInfo(index = true)
val tripId: Long,
/** Wall-clock epoch ms of the first qualifying sensor frame. */
val timestamp: Long,
/** EventType.name — one of BRAKING, TURNING, STOPPING. */
val type: String,
/** Confidence.name — one of HIGH, MEDIUM, LOW. */
val confidence: String,
val latitude: Double,
val longitude: Double,
val speedMps: Float,
val peakAccelMagnitude: Float,
val peakGyroMagnitude: Float,
/** Duration from first qualifying frame to emission (ms). */
val durationMs: Long,
)
@@ -0,0 +1,21 @@
package com.hawhamburg.micr0bu.data.db
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* One recorded bike trip stored in the local Room database.
*
* [gpsTrackJson] is a compact JSON array of GPS points:
* `[{"lat":53.5,"lon":10.0,"ts":1717000000000}, ...]`
*/
@Entity(tableName = "trips")
data class RecordedTripEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val startTime: Long,
val endTime: Long,
val distanceMetres: Float,
val eventCount: Int,
val gpsTrackJson: String,
)
@@ -0,0 +1,20 @@
package com.hawhamburg.micr0bu.data.db
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface SessionDao {
@Query("SELECT * FROM sessions ORDER BY startTime DESC")
fun getAllSessions(): Flow<List<SessionEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertSession(session: SessionEntity)
@Query("DELETE FROM sessions WHERE id = :id")
suspend fun deleteById(id: String)
}
@@ -0,0 +1,13 @@
package com.hawhamburg.micr0bu.data.db
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "sessions")
data class SessionEntity(
@PrimaryKey val id: String,
val startTime: Long,
val endTime: Long,
val durationSeconds: Long,
val sampleCount: Int,
)
@@ -0,0 +1,40 @@
package com.hawhamburg.micr0bu.data.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface TripDao {
// ── Trips ─────────────────────────────────────────────────────────────────
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTrip(trip: RecordedTripEntity): Long
@Update
suspend fun updateTrip(trip: RecordedTripEntity)
@Query("SELECT * FROM trips ORDER BY startTime DESC")
fun getAllTrips(): Flow<List<RecordedTripEntity>>
@Query("SELECT * FROM trips WHERE id = :id")
suspend fun getTripById(id: Long): RecordedTripEntity?
@Query("DELETE FROM trips WHERE id = :id")
suspend fun deleteTripById(id: Long)
// ── Events ────────────────────────────────────────────────────────────────
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertEvent(event: DetectedEventEntity)
@Query("SELECT * FROM detected_events WHERE tripId = :tripId ORDER BY timestamp ASC")
fun getEventsForTrip(tripId: Long): Flow<List<DetectedEventEntity>>
@Query("SELECT COUNT(*) FROM detected_events WHERE tripId = :tripId")
suspend fun getEventCountForTrip(tripId: Long): Int
}
@@ -0,0 +1,57 @@
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()
}
}
@@ -0,0 +1,8 @@
package com.hawhamburg.micr0bu.data.mqtt
enum class MqttConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED,
ERROR,
}
@@ -0,0 +1,10 @@
package com.hawhamburg.micr0bu.data.mqtt
enum class MessageDirection { RX, TX }
data class MqttMessage(
val topic: String,
val payload: String,
val timestamp: Long,
val direction: MessageDirection = MessageDirection.RX,
)
@@ -0,0 +1,81 @@
package com.hawhamburg.micr0bu.data.mqtt
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
private val Context.mqttDataStore by preferencesDataStore(name = "mqtt_prefs")
data class MqttPrefs(
// ── Wi-Fi / direct MQTT broker ──────────────────────────────────────────
val brokerIp: String = DEFAULT_IP,
val brokerPort: Int = DEFAULT_PORT,
val username: String = "",
val password: String = "",
// ── USB-C transport ──────────────────────────────────────────────────────
/** When true, the detected gateway IP on the 192.168.42.x USB interface is used as broker IP. */
val usbAutoDetect: Boolean = true,
/** Manual IP override — used when [usbAutoDetect] is false or detection fails. */
val usbIpOverride: String = DEFAULT_USB_IP,
/**
* Active transport: "USB_C" (default) or "WIFI".
* Stored as a string so we can add more transports later without a migration.
*/
val activeTransport: String = TRANSPORT_USB_C,
) {
companion object {
const val DEFAULT_IP = "192.168.3.202"
const val DEFAULT_PORT = 1883
const val DEFAULT_USB_IP = "192.168.42.1"
const val TRANSPORT_USB_C = "USB_C"
const val TRANSPORT_WIFI = "WIFI"
}
}
@Singleton
class MqttPreferences @Inject constructor(
@ApplicationContext private val context: Context,
) {
private object Keys {
val BROKER_IP = stringPreferencesKey("broker_ip")
val BROKER_PORT = intPreferencesKey("broker_port")
val USERNAME = stringPreferencesKey("username")
val PASSWORD = stringPreferencesKey("password")
val USB_AUTO_DETECT = booleanPreferencesKey("usb_auto_detect")
val USB_IP_OVERRIDE = stringPreferencesKey("usb_ip_override")
val ACTIVE_TRANSPORT = stringPreferencesKey("active_transport")
}
val prefsFlow: Flow<MqttPrefs> = context.mqttDataStore.data.map { prefs ->
MqttPrefs(
brokerIp = prefs[Keys.BROKER_IP] ?: MqttPrefs.DEFAULT_IP,
brokerPort = prefs[Keys.BROKER_PORT] ?: MqttPrefs.DEFAULT_PORT,
username = prefs[Keys.USERNAME] ?: "",
password = prefs[Keys.PASSWORD] ?: "",
usbAutoDetect = prefs[Keys.USB_AUTO_DETECT] ?: true,
usbIpOverride = prefs[Keys.USB_IP_OVERRIDE] ?: MqttPrefs.DEFAULT_USB_IP,
activeTransport = prefs[Keys.ACTIVE_TRANSPORT] ?: MqttPrefs.TRANSPORT_USB_C,
)
}
suspend fun update(p: MqttPrefs) {
context.mqttDataStore.edit { prefs ->
prefs[Keys.BROKER_IP] = p.brokerIp
prefs[Keys.BROKER_PORT] = p.brokerPort
prefs[Keys.USERNAME] = p.username
prefs[Keys.PASSWORD] = p.password
prefs[Keys.USB_AUTO_DETECT] = p.usbAutoDetect
prefs[Keys.USB_IP_OVERRIDE] = p.usbIpOverride
prefs[Keys.ACTIVE_TRANSPORT] = p.activeTransport
}
}
}
@@ -0,0 +1,245 @@
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.suspendCancellableCoroutine
import org.eclipse.paho.client.mqttv3.IMqttActionListener
import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken
import org.eclipse.paho.client.mqttv3.IMqttToken
import org.eclipse.paho.client.mqttv3.MqttAsyncClient
import org.eclipse.paho.client.mqttv3.MqttCallback
import org.eclipse.paho.client.mqttv3.MqttConnectOptions
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import org.eclipse.paho.client.mqttv3.MqttMessage as PahoMqttMessage
private val SUBSCRIBED_TOPICS = listOf(
"sys/state/heartbeat",
"sys/state/cellular",
"v2x/rx/obu_gnss",
"v2x-uca/output/json/cam",
"v2x-uca/output/json/denm",
"v2x-uca/output/json/spat",
"v2x-uca/output/json/map",
"v2x-uca/output/json/cpm",
)
@Singleton
class MqttRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val prefs: MqttPreferences,
private val usbDetector: UsbNetworkDetector,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val clientId = "micr0bu-android-${UUID.randomUUID()}"
private val _connectionState = MutableStateFlow(MqttConnectionState.DISCONNECTED)
val connectionState: StateFlow<MqttConnectionState> = _connectionState.asStateFlow()
// extraBufferCapacity absorbs bursts (e.g. spat at 2 Hz, cam live) without blocking Paho's thread
private val _messages = MutableSharedFlow<MqttMessage>(
replay = 0,
extraBufferCapacity = 512,
)
val messages: SharedFlow<MqttMessage> = _messages.asSharedFlow()
/** Active transport derived from persisted prefs. */
val activeTransport: StateFlow<TransportType> = prefs.prefsFlow
.map { p ->
if (p.activeTransport == MqttPrefs.TRANSPORT_USB_C) TransportType.USB_C
else TransportType.WIFI
}
.stateIn(scope, SharingStarted.Eagerly, TransportType.USB_C)
@Volatile private var activeClient: MqttAsyncClient? = null
private var connectJob: Job? = null
// ── Public API ────────────────────────────────────────────────────────────
fun connect() {
connectJob?.cancel()
connectJob = scope.launch { connectWithRetry() }
}
fun disconnect() {
connectJob?.cancel()
connectJob = null
scope.launch {
runCatching { activeClient?.disconnectForcibly(0, 200) }
runCatching { activeClient?.close() }
activeClient = null
_connectionState.value = MqttConnectionState.DISCONNECTED
}
}
/**
* 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.
* Required for DENM control messages on v2x-uca/input/denmtrg.
*/
fun publishRetained(topic: String, payload: String, qos: Int = 1) {
val client = activeClient ?: return
if (!client.isConnected) return
scope.launch {
runCatching {
val msg = PahoMqttMessage(payload.toByteArray(Charsets.UTF_8))
msg.qos = qos
msg.isRetained = true
client.publish(topic, msg)
}
}
}
// ── Internal ─────────────────────────────────────────────────────────────
private suspend fun connectWithRetry() {
var attempt = 0
while (true) {
try {
_connectionState.value = MqttConnectionState.CONNECTING
doConnect()
// doConnect suspends until the connection drops; reaching here means it dropped
_connectionState.value = MqttConnectionState.ERROR
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (_: Exception) {
_connectionState.value = MqttConnectionState.ERROR
}
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s (capped)
val backoffMs = minOf(1_000L * (1L shl minOf(attempt, 4)), 30_000L)
attempt++
delay(backoffMs)
}
}
private suspend fun doConnect() {
val currentPrefs = prefs.prefsFlow.first()
val isUsbC = currentPrefs.activeTransport == MqttPrefs.TRANSPORT_USB_C
val usbNet = usbDetector.usbNetwork.value // non-null only when cable is plugged in
val usbActive = isUsbC && usbNet != null // true = USB-C mode AND cable detected
// Resolve broker IP:
// • USB-C + cable present → OBU gateway IP on 192.168.42.x (auto or manual)
// • USB-C + no cable → fall back to the Wi-Fi broker IP so dev workflow still works
// • Wi-Fi transport → configured broker IP
val brokerIp: String = when {
usbActive && currentPrefs.usbAutoDetect ->
usbDetector.detectedGatewayIp.value ?: currentPrefs.usbIpOverride
usbActive ->
currentPrefs.usbIpOverride
else ->
// Wi-Fi transport, or USB-C selected but no cable → use Wi-Fi broker IP
currentPrefs.brokerIp
}
val serverUri = "tcp://$brokerIp:${currentPrefs.brokerPort}"
// Create fresh client every attempt; MemoryPersistence is fine at QoS 0
runCatching { activeClient?.disconnectForcibly(0, 200) }
runCatching { activeClient?.close() }
val client = MqttAsyncClient(serverUri, clientId, MemoryPersistence())
activeClient = client
// Signal that connection was lost — set before connect so no window is missed
val lostSignal = kotlinx.coroutines.CompletableDeferred<Throwable?>()
client.setCallback(object : MqttCallback {
override fun connectionLost(cause: Throwable?) { lostSignal.complete(cause) }
override fun messageArrived(topic: String, message: PahoMqttMessage) {
_messages.tryEmit(
MqttMessage(
topic = topic,
payload = message.payload.toString(Charsets.UTF_8),
timestamp = System.currentTimeMillis(),
)
)
}
override fun deliveryComplete(token: IMqttDeliveryToken) {}
})
val options = MqttConnectOptions().apply {
connectionTimeout = 10
keepAliveInterval = 30
isCleanSession = true
isAutomaticReconnect = false // we handle reconnect ourselves
if (currentPrefs.username.isNotBlank()) {
userName = currentPrefs.username
password = currentPrefs.password.toCharArray()
}
// 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
}
}
// Connect (bridge callback → coroutine)
suspendCancellableCoroutine { cont ->
client.connect(options, null, object : IMqttActionListener {
override fun onSuccess(token: IMqttToken) = cont.resume(Unit) {}
override fun onFailure(token: IMqttToken, ex: Throwable) =
cont.resumeWithException(ex)
})
cont.invokeOnCancellation { runCatching { client.disconnectForcibly(0, 200) } }
}
// Subscribe to all topics at QoS 0 (fire-and-forget delivery)
val topicsArr = SUBSCRIBED_TOPICS.toTypedArray()
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 onFailure(token: IMqttToken, ex: Throwable) =
cont.resumeWithException(ex)
})
}
_connectionState.value = MqttConnectionState.CONNECTED
// Suspend here until the broker drops the connection
val cause = lostSignal.await()
throw Exception(cause?.message ?: "Connection lost", cause)
}
}
@@ -0,0 +1,3 @@
package com.hawhamburg.micr0bu.data.transport
enum class TransportType { USB_C, WIFI, BLUETOOTH }
@@ -0,0 +1,82 @@
package com.hawhamburg.micr0bu.data.transport
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Watches all Android network interfaces and identifies the OBU USB-C tethering link
* by looking for a non-WiFi / non-cellular interface with a 192.168.42.x address.
*
* The OBU acts as a USB NCM/tethering host:
* - Phone address : 192.168.42.129 (DHCP)
* - OBU (gateway) : typically 192.168.42.1 or as reported by the default route
*
* Once detected, [usbNetwork] holds the Android [Network] object whose [Network.socketFactory]
* is passed to Paho's [MqttConnectOptions] to bind all MQTT sockets to that interface.
*/
@Singleton
class UsbNetworkDetector @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val _usbNetwork = MutableStateFlow<Network?>(null)
/** Non-null when a 192.168.42.x USB tethering network is active. */
val usbNetwork: StateFlow<Network?> = _usbNetwork.asStateFlow()
private val _detectedGatewayIp = MutableStateFlow<String?>(null)
/**
* IP of the default gateway on the USB interface — almost always the OBU's IP.
* Falls back to <phoneSubnet>.1 if no default route is reported.
*/
val detectedGatewayIp: StateFlow<String?> = _detectedGatewayIp.asStateFlow()
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = checkAndSet(network)
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) =
checkAndSet(network)
override fun onLost(network: Network) {
if (_usbNetwork.value == network) {
_usbNetwork.value = null
_detectedGatewayIp.value = null
}
}
}
init {
// Register for ALL networks — we inspect each one to find the USB tethering link.
cm.registerNetworkCallback(NetworkRequest.Builder().build(), callback)
}
private fun checkAndSet(network: Network) {
val caps = cm.getNetworkCapabilities(network) ?: return
val lp = cm.getLinkProperties(network) ?: return
// Ignore WiFi and cellular — USB tethering shows up as Ethernet or an untyped transport
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) return
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) return
// Find a link address in the 192.168.42.x subnet
val usbAddr = lp.linkAddresses.firstOrNull { la ->
la.address.hostAddress?.startsWith("192.168.42.") == true
} ?: return
_usbNetwork.value = network
// Prefer the default-route gateway; fall back to subnet.1
val gateway = lp.routes
.firstOrNull { it.isDefaultRoute && it.gateway != null }
?.gateway?.hostAddress
val phoneIp = usbAddr.address.hostAddress ?: return
_detectedGatewayIp.value = gateway ?: "${phoneIp.substringBeforeLast('.')}.1"
}
}
@@ -0,0 +1,38 @@
package com.hawhamburg.micr0bu.domain.detection
/** The class of cyclist manoeuvre that was detected. */
enum class EventType { BRAKING, TURNING, STOPPING }
/** Confidence level assigned to a detected event. */
enum class Confidence { HIGH, MEDIUM, LOW }
/**
* A single cyclist event produced by [EventDetector].
*
* This is a pure domain model — no Android or Room imports.
*/
data class DetectedEvent(
val type: EventType,
val confidence: Confidence,
/** Wall-clock milliseconds (epoch) of the first qualifying frame. */
val timestamp: Long,
/** GPS latitude at the event location. */
val latitude: Double,
/** GPS longitude at the event location. */
val longitude: Double,
/** GPS speed at the moment of emission (m/s). */
val speedMps: Double,
/** Peak accelerometer magnitude observed during the event window (m/s²). */
val peakAccelMagnitude: Double,
/** Peak gyroscope magnitude observed during the event window (rad/s). */
val peakGyroMagnitude: Double,
/** Duration from first qualifying frame to emission (ms). */
val durationMs: Long,
)
@@ -0,0 +1,58 @@
package com.hawhamburg.micr0bu.domain.detection
/**
* All detection thresholds in one place.
*
* Pass a custom instance to [EventDetector] to tune behaviour without
* recompiling. The defaults match the Phase A specification.
*/
data class DetectionConfig(
// ── Sliding window ────────────────────────────────────────────────────────
/** Number of sensor frames in the statistics window. 50 ≈ 1 s at 50 Hz. */
val windowSize: Int = 50,
// ── BRAKING ───────────────────────────────────────────────────────────────
/**
* Minimum speed drop (m/s) from the reference speed at braking onset for
* a frame to qualify as a braking frame.
*/
val brakingSpeedDropThreshold: Double = 0.5,
/** Minimum accel std-dev (m/s²) required for a frame to count as braking. */
val brakingAccelStdDevThreshold: Double = 1.2,
/** Consecutive braking frames required before an event is emitted. */
val brakingSustainedFrames: Int = 15,
/**
* Peak speed-drop rate (m/s per GPS update ≈ m/s²) above which the braking
* confidence is upgraded from MEDIUM to HIGH.
*/
val brakingHighConfidenceRate: Double = 1.5,
// ── TURNING ───────────────────────────────────────────────────────────────
/** Minimum gyro mean (rad/s) required for a frame to qualify as turning. */
val turningGyroMeanThreshold: Double = 0.4,
/** Bearing-change rate (°/s) that must be exceeded when speed is above the
* minimum threshold for a HIGH-confidence turning confirmation. */
val turningBearingChangeThreshold: Double = 10.0,
/** GPS speed (m/s) above which the bearing-change criterion is enforced. */
val turningMinSpeedThreshold: Double = 2.0,
/** Consecutive turning frames required before an event is emitted. */
val turningSustainedFrames: Int = 20,
// ── STOPPING ─────────────────────────────────────────────────────────────
/** GPS speed (m/s) below which a frame is considered a potential stop. */
val stoppingSpeedThreshold: Double = 0.5,
/** Consecutive stop frames required (> this value) before an event is emitted.
* At 50 Hz, 100 frames ≈ 2 s. */
val stoppingFrames: Int = 100,
/** Maximum accel std-dev (m/s²) allowed for a frame to count as stationary. */
val stoppingAccelStdDevThreshold: Double = 0.15,
)
@@ -0,0 +1,247 @@
package com.hawhamburg.micr0bu.domain.detection
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlin.math.abs
/**
* Pure-Kotlin event detector for cyclist manoeuvres.
*
* **No Android imports** — runs on the JVM in unit tests without an emulator.
*
* Feed one sample at a time via [processSample]. Detected events are emitted
* to [events] (a hot [SharedFlow]). Debounce is implemented with
* consecutive-frame counters, not timers.
*
* GPS updates at 1 Hz whilst sensors fire at ~50 Hz. [speedMps] and
* [bearingChangeDegPerSec] should be the values from the last known GPS fix;
* the detector compares speed against a *reference speed at braking onset*
* rather than a per-frame delta, so the condition remains true across the 50
* sensor frames that fall between consecutive 1-Hz GPS updates.
*/
class EventDetector(private val config: DetectionConfig = DetectionConfig()) {
// ── Statistics windows ────────────────────────────────────────────────────
private val accelStats = RunningStats(config.windowSize)
private val gyroStats = RunningStats(config.windowSize)
// ── Events flow ───────────────────────────────────────────────────────────
private val _events = MutableSharedFlow<DetectedEvent>(extraBufferCapacity = 64)
val events: SharedFlow<DetectedEvent> = _events.asSharedFlow()
// ── Braking state ─────────────────────────────────────────────────────────
private var brakingFrames = 0
private var brakingOnsetSpeed = 0.0 // reference speed when braking started
private var brakingStartTime = 0L
private var peakBrakingDrop = 0.0 // peak speed drop observed during this window
private var peakAccelBraking = 0.0
// ── Turning state ─────────────────────────────────────────────────────────
private var turningFrames = 0
private var turningStartTime = 0L
private var turningBothAgreed = false
private var peakGyroTurning = 0.0
// ── Stopping state ────────────────────────────────────────────────────────
private var stoppingFrames = 0
private var stoppingStartTime = 0L
// ── Previous-frame tracking ───────────────────────────────────────────────
private var prevSpeedMps = 0.0
// ── Last known location ───────────────────────────────────────────────────
private var lastLat = 0.0
private var lastLon = 0.0
// ─────────────────────────────────────────────────────────────────────────
/**
* Process one sensor frame.
*
* @param accelMagnitude orientation-independent accelerometer magnitude
* (√(x²+y²+z²) − 9.81, i.e. net dynamic acceleration in m/s²).
* @param gyroMagnitude orientation-independent gyroscope magnitude (√(x²+y²+z²), rad/s).
* @param speedMps GPS speed at the last known fix (m/s).
* @param bearingChangeDegPerSec |Δbearing| / Δt from the last two GPS fixes (°/s).
* @param latitude GPS latitude at the last known fix.
* @param longitude GPS longitude at the last known fix.
* @param timestamp Wall-clock epoch ms for this frame (defaults to now).
*/
fun processSample(
accelMagnitude: Double,
gyroMagnitude: Double,
speedMps: Double,
bearingChangeDegPerSec: Double,
latitude: Double,
longitude: Double,
timestamp: Long = System.currentTimeMillis(),
) {
lastLat = latitude
lastLon = longitude
accelStats.add(accelMagnitude)
gyroStats.add(gyroMagnitude)
detectBraking(accelMagnitude, speedMps, timestamp)
detectTurning(gyroMagnitude, speedMps, bearingChangeDegPerSec, timestamp)
detectStopping(speedMps, timestamp)
prevSpeedMps = speedMps
}
// ── BRAKING ───────────────────────────────────────────────────────────────
private fun detectBraking(accelMagnitude: Double, speedMps: Double, timestamp: Long) {
// Compute the drop against the reference speed captured at onset.
// On the very first frame of a potential braking episode we compare against
// prevSpeedMps; on subsequent frames we compare against brakingOnsetSpeed.
val referenceSpeed = if (brakingFrames == 0) prevSpeedMps else brakingOnsetSpeed
val speedDrop = referenceSpeed - speedMps
val isBrakingFrame =
speedDrop > config.brakingSpeedDropThreshold &&
accelStats.stdDev() > config.brakingAccelStdDevThreshold
if (isBrakingFrame) {
if (brakingFrames == 0) {
brakingOnsetSpeed = prevSpeedMps
brakingStartTime = timestamp
peakBrakingDrop = 0.0
peakAccelBraking = 0.0
}
brakingFrames++
peakBrakingDrop = maxOf(peakBrakingDrop, speedDrop)
peakAccelBraking = maxOf(peakAccelBraking, accelMagnitude)
if (brakingFrames == config.brakingSustainedFrames) {
val confidence =
if (peakBrakingDrop > config.brakingHighConfidenceRate) Confidence.HIGH
else Confidence.MEDIUM
_events.tryEmit(
DetectedEvent(
type = EventType.BRAKING,
confidence = confidence,
timestamp = brakingStartTime,
latitude = lastLat,
longitude = lastLon,
speedMps = speedMps,
peakAccelMagnitude = peakAccelBraking,
peakGyroMagnitude = gyroStats.mean(),
durationMs = timestamp - brakingStartTime,
)
)
// Debounce: reset and require another full window to re-trigger.
brakingFrames = 0
}
} else {
brakingFrames = 0
}
}
// ── TURNING ───────────────────────────────────────────────────────────────
private fun detectTurning(
gyroMagnitude: Double,
speedMps: Double,
bearingChangeDegPerSec: Double,
timestamp: Long,
) {
val gyroAbove = gyroStats.mean() > config.turningGyroMeanThreshold
val speedAbove = speedMps > config.turningMinSpeedThreshold
val bearingAbove = bearingChangeDegPerSec > config.turningBearingChangeThreshold
// A frame qualifies if gyro is elevated AND either:
// • the cyclist is moving fast enough and bearing confirms the turn, or
// • the cyclist is moving slowly (gyro alone is sufficient).
val isTurningFrame = gyroAbove && (!speedAbove || bearingAbove)
if (isTurningFrame) {
if (turningFrames == 0) {
turningStartTime = timestamp
peakGyroTurning = 0.0
turningBothAgreed = false
}
turningFrames++
peakGyroTurning = maxOf(peakGyroTurning, gyroMagnitude)
if (gyroAbove && speedAbove && bearingAbove) turningBothAgreed = true
if (turningFrames == config.turningSustainedFrames) {
val confidence = if (turningBothAgreed) Confidence.HIGH else Confidence.LOW
_events.tryEmit(
DetectedEvent(
type = EventType.TURNING,
confidence = confidence,
timestamp = turningStartTime,
latitude = lastLat,
longitude = lastLon,
speedMps = speedMps,
peakAccelMagnitude = accelStats.mean(),
peakGyroMagnitude = peakGyroTurning,
durationMs = timestamp - turningStartTime,
)
)
turningFrames = 0
}
} else {
turningFrames = 0
}
}
// ── STOPPING ─────────────────────────────────────────────────────────────
private fun detectStopping(speedMps: Double, timestamp: Long) {
val isStoppingFrame =
speedMps < config.stoppingSpeedThreshold &&
accelStats.stdDev() < config.stoppingAccelStdDevThreshold
if (isStoppingFrame) {
if (stoppingFrames == 0) {
stoppingStartTime = timestamp
}
stoppingFrames++
// Emit exactly once when the threshold is first crossed.
if (stoppingFrames == config.stoppingFrames + 1) {
_events.tryEmit(
DetectedEvent(
type = EventType.STOPPING,
confidence = Confidence.HIGH,
timestamp = stoppingStartTime,
latitude = lastLat,
longitude = lastLon,
speedMps = speedMps,
peakAccelMagnitude = accelStats.mean(),
peakGyroMagnitude = gyroStats.mean(),
durationMs = timestamp - stoppingStartTime,
)
)
}
} else {
stoppingFrames = 0
}
}
// ─────────────────────────────────────────────────────────────────────────
/** Resets all running state (e.g. between trips). */
fun reset() {
accelStats.reset()
gyroStats.reset()
brakingFrames = 0
turningFrames = 0
stoppingFrames = 0
prevSpeedMps = 0.0
lastLat = 0.0
lastLon = 0.0
}
}
@@ -0,0 +1,66 @@
package com.hawhamburg.micr0bu.domain.detection
import kotlin.math.max
import kotlin.math.sqrt
/**
* O(1) sliding-window statistics over the last [windowSize] doubles.
*
* Maintains a circular buffer and updates sum / sum-of-squares accumulators
* incrementally, so every add() call is O(1) regardless of window size.
*
* No Android dependencies — fully JVM-testable.
*/
class RunningStats(val windowSize: Int = 50) {
private val buffer = DoubleArray(windowSize)
private var head = 0 // next write index in the circular buffer
private var count = 0 // number of samples added so far (capped at windowSize)
private var sum = 0.0
private var sumSq = 0.0
/** Adds [value] to the window, evicting the oldest sample when full. */
fun add(value: Double) {
if (count == windowSize) {
// Evict the oldest value
val oldest = buffer[head]
sum -= oldest
sumSq -= oldest * oldest
} else {
count++
}
buffer[head] = value
sum += value
sumSq += value * value
head = (head + 1) % windowSize
}
/** Arithmetic mean of the current window. Returns 0 if the window is empty. */
fun mean(): Double = if (count == 0) 0.0 else sum / count
/**
* Population variance of the current window.
* Uses the Welford/accumulator identity: Var = E[x²] - E[x]²
* Clamped to ≥ 0 to avoid tiny negative floats from rounding errors.
*/
fun variance(): Double {
if (count < 2) return 0.0
val m = mean()
return max(0.0, (sumSq / count) - (m * m))
}
/** Population standard deviation of the current window. */
fun stdDev(): Double = sqrt(variance())
/** Number of samples currently in the window (up to [windowSize]). */
fun size(): Int = count
/** Clears the window, resetting all accumulators. */
fun reset() {
buffer.fill(0.0)
head = 0
count = 0
sum = 0.0
sumSq = 0.0
}
}
@@ -0,0 +1,413 @@
package com.hawhamburg.micr0bu.service
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.location.Location
import android.os.IBinder
import android.os.Looper
import androidx.core.app.NotificationCompat
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import com.hawhamburg.micr0bu.MainActivity
import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.TripRepository
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.sqrt
/**
* Foreground service that runs event detection while the screen is off.
*
* Start with [ACTION_START], stop with [ACTION_STOP].
*
* Sensor pipeline:
* • Accelerometer + Gyroscope at [SensorManager.SENSOR_DELAY_GAME] (~50 Hz)
* • FusedLocationProviderClient at 1 Hz
* • Accel/gyro magnitudes + last GPS state → [EventDetector] → Room via [TripRepository]
*/
class TripRecordingService : Service() {
// ── Constants ─────────────────────────────────────────────────────────────
companion object {
const val ACTION_START = "com.hawhamburg.micr0bu.TRIP_START"
const val ACTION_STOP = "com.hawhamburg.micr0bu.TRIP_STOP"
private const val NOTIFICATION_ID = 9001
private const val CHANNEL_ID = "trip_recording"
private const val CHANNEL_NAME = "Trip Recording"
private const val GPS_INTERVAL_MS = 1_000L
private const val GRAVITY = 9.81
}
// ── Service scope ─────────────────────────────────────────────────────────
private val serviceJob = SupervisorJob()
private val serviceScope = CoroutineScope(Dispatchers.Default + serviceJob)
private var timerJob: Job? = null
// ── Dependencies ──────────────────────────────────────────────────────────
private lateinit var sensorManager: SensorManager
private lateinit var fusedLocation: FusedLocationProviderClient
private lateinit var repository: TripRepository
private val detector = EventDetector(
DetectionConfig(
brakingSpeedDropThreshold = 1.0,
brakingAccelStdDevThreshold = 1.8,
brakingSustainedFrames = 25,
turningGyroMeanThreshold = 0.6,
turningBearingChangeThreshold = 15.0,
turningSustainedFrames = 30,
stoppingSpeedThreshold = 0.3,
stoppingFrames = 150,
stoppingAccelStdDevThreshold = 0.10,
)
)
// ── Sensor fusion state ───────────────────────────────────────────────────
// Latest accel xyz (updated at ~50 Hz)
@Volatile private var ax = 0f
@Volatile private var ay = 0f
@Volatile private var az = 0f
// Latest gyro xyz (updated at ~50 Hz)
@Volatile private var gx = 0f
@Volatile private var gy = 0f
@Volatile private var gz = 0f
// Latest GPS state (updated at 1 Hz)
@Volatile private var currentLat = 0.0
@Volatile private var currentLon = 0.0
@Volatile private var currentSpeedMps = 0f
@Volatile private var currentBearing = 0f
@Volatile private var previousBearing = 0f
@Volatile private var bearingChangeDps = 0.0 // °/s
// ── Trip accounting ───────────────────────────────────────────────────────
private var currentTripId: Long = -1L
private var distanceMetres = 0f
private var prevLat = Double.NaN
private var prevLon = Double.NaN
// GPS track for map replay: list of JSON objects built as a StringBuilder
private val gpsTrackBuilder = StringBuilder("[")
private var gpsPointCount = 0
// Event counts
private var brakingCount = 0
private var turningCount = 0
private var stoppingCount = 0
// ── SensorEventListener ───────────────────────────────────────────────────
private val sensorListener = object : SensorEventListener {
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
override fun onSensorChanged(event: SensorEvent) {
when (event.sensor.type) {
Sensor.TYPE_ACCELEROMETER -> {
ax = event.values[0]
ay = event.values[1]
az = event.values[2]
}
Sensor.TYPE_GYROSCOPE -> {
gx = event.values[0]
gy = event.values[1]
gz = event.values[2]
}
}
// Compute orientation-independent magnitudes and feed the detector
val accelMag = sqrt((ax * ax + ay * ay + az * az).toDouble()) - GRAVITY
val gyroMag = sqrt((gx * gx + gy * gy + gz * gz).toDouble())
detector.processSample(
accelMagnitude = accelMag,
gyroMagnitude = gyroMag,
speedMps = currentSpeedMps.toDouble(),
bearingChangeDegPerSec = bearingChangeDps,
latitude = currentLat,
longitude = currentLon,
timestamp = System.currentTimeMillis(),
)
}
}
// ── LocationCallback ─────────────────────────────────────────────────────
private val locationCallback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
val loc = result.lastLocation ?: return
// Update bearing-change rate before overwriting previous bearing
val newBearing = loc.bearing
bearingChangeDps = normaliseBearingDiff(previousBearing, newBearing)
previousBearing = currentBearing
currentBearing = newBearing
currentLat = loc.latitude
currentLon = loc.longitude
currentSpeedMps = loc.speed
TripServiceBus.update { copy(currentSpeedMs = loc.speed) }
accumulateDistance(loc)
appendGpsPoint(loc)
}
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
override fun onCreate() {
super.onCreate()
sensorManager = getSystemService(SENSOR_SERVICE) as SensorManager
fusedLocation = LocationServices.getFusedLocationProviderClient(this)
repository = TripRepository(AppDatabase.getInstance(applicationContext))
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> startTrip()
ACTION_STOP -> stopTrip()
}
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
serviceJob.cancel()
super.onDestroy()
}
// ── Trip control ─────────────────────────────────────────────────────────
private fun startTrip() {
detector.reset()
brakingCount = 0
turningCount = 0
stoppingCount = 0
distanceMetres = 0f
prevLat = Double.NaN
prevLon = Double.NaN
gpsTrackBuilder.clear()
gpsTrackBuilder.append("[")
gpsPointCount = 0
val startTime = System.currentTimeMillis()
// Insert placeholder trip row and get the auto-generated ID
serviceScope.launch {
currentTripId = repository.insertTrip(startTime = startTime)
TripServiceBus.update {
copy(
isRecording = true,
currentTripId = currentTripId,
elapsedSeconds = 0L,
brakingCount = 0,
turningCount = 0,
stoppingCount = 0,
currentSpeedMs = 0f,
)
}
}
// Collect detector events and persist them
serviceScope.launch {
detector.events.collect { event ->
if (currentTripId < 0) return@collect
repository.insertEvent(currentTripId, event)
when (event.type) {
EventType.BRAKING -> brakingCount++
EventType.TURNING -> turningCount++
EventType.STOPPING -> stoppingCount++
}
TripServiceBus.update {
copy(
brakingCount = this@TripRecordingService.brakingCount,
turningCount = this@TripRecordingService.turningCount,
stoppingCount = this@TripRecordingService.stoppingCount,
)
}
updateNotification()
}
}
// Elapsed-time ticker
timerJob = serviceScope.launch {
var elapsed = 0L
while (true) {
delay(1_000)
elapsed++
TripServiceBus.update { copy(elapsedSeconds = elapsed) }
updateNotification()
}
}
// Register sensors
registerSensors()
requestLocationUpdates()
// Start foreground ASAP (within 5 s required by Android)
startForeground(NOTIFICATION_ID, buildNotification(0L, 0))
}
private fun stopTrip() {
timerJob?.cancel()
unregisterSensors()
removeLocationUpdates()
val endTime = System.currentTimeMillis()
val totalEvents = brakingCount + turningCount + stoppingCount
// Close GPS track JSON
gpsTrackBuilder.append("]")
val gpsJson = gpsTrackBuilder.toString()
serviceScope.launch {
if (currentTripId >= 0) {
repository.finaliseTrip(
tripId = currentTripId,
endTime = endTime,
distanceMetres = distanceMetres,
eventCount = totalEvents,
gpsTrackJson = gpsJson,
)
}
currentTripId = -1L
TripServiceBus.reset()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
// ── Sensors ───────────────────────────────────────────────────────────────
private fun registerSensors() {
listOf(Sensor.TYPE_ACCELEROMETER, Sensor.TYPE_GYROSCOPE).forEach { type ->
sensorManager.getDefaultSensor(type)?.let { sensor ->
sensorManager.registerListener(
sensorListener, sensor, SensorManager.SENSOR_DELAY_GAME
)
}
}
}
private fun unregisterSensors() =
sensorManager.unregisterListener(sensorListener)
// ── Location ─────────────────────────────────────────────────────────────
@Suppress("MissingPermission")
private fun requestLocationUpdates() {
val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, GPS_INTERVAL_MS)
.setMinUpdateIntervalMillis(GPS_INTERVAL_MS / 2)
.build()
fusedLocation.requestLocationUpdates(request, locationCallback, Looper.getMainLooper())
}
private fun removeLocationUpdates() =
fusedLocation.removeLocationUpdates(locationCallback)
// ── GPS track helpers ─────────────────────────────────────────────────────
private fun accumulateDistance(loc: Location) {
val pLat = prevLat
val pLon = prevLon
if (!pLat.isNaN() && !pLon.isNaN()) {
val results = FloatArray(1)
Location.distanceBetween(pLat, pLon, loc.latitude, loc.longitude, results)
distanceMetres += results[0]
}
prevLat = loc.latitude
prevLon = loc.longitude
}
private fun appendGpsPoint(loc: Location) {
if (gpsPointCount > 0) gpsTrackBuilder.append(",")
gpsTrackBuilder.append(
"""{"lat":${loc.latitude},"lon":${loc.longitude},"ts":${loc.time}}"""
)
gpsPointCount++
}
private fun normaliseBearingDiff(a: Float, b: Float): Double {
// Returns absolute bearing change in °/s (GPS updates at ~1 Hz)
var diff = (b - a).toDouble()
while (diff > 180) diff -= 360
while (diff < -180) diff += 360
return abs(diff) // per second because GPS is 1 Hz
}
// ── Notification ─────────────────────────────────────────────────────────
private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Shown while a trip is being recorded"
}
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager)
.createNotificationChannel(channel)
}
private fun buildNotification(elapsedSeconds: Long, eventCount: Int) =
NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Recording trip")
.setContentText(
"⏱ ${formatElapsed(elapsedSeconds)} · " +
"🚨 $brakingCount 🔄 $turningCount 🛑 $stoppingCount"
)
.setSmallIcon(R.mipmap.ic_launcher_foreground)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setContentIntent(
PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE,
)
)
.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))
}
private fun formatElapsed(seconds: Long): String {
val h = seconds / 3600
val m = (seconds % 3600) / 60
val s = seconds % 60
return if (h > 0) "%d:%02d:%02d".format(h, m, s)
else "%02d:%02d".format(m, s)
}
}
@@ -0,0 +1,38 @@
package com.hawhamburg.micr0bu.service
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Process-level singleton that bridges [TripRecordingService] (writes) and
* [TripRecordingViewModel] (reads) without requiring a bound service.
*
* The service updates [state] on every relevant change; the ViewModel collects
* it as a cold StateFlow.
*/
object TripServiceBus {
data class State(
val isRecording: Boolean = false,
val currentTripId: Long = -1L,
val elapsedSeconds: Long = 0L,
val brakingCount: Int = 0,
val turningCount: Int = 0,
val stoppingCount: Int = 0,
val currentSpeedMs: Float = 0f,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
/** Called exclusively from [TripRecordingService]. */
internal fun update(transform: State.() -> State) {
_state.value = _state.value.transform()
}
/** Resets to idle — called when recording stops. */
internal fun reset() {
_state.value = State()
}
}
@@ -0,0 +1,169 @@
package com.hawhamburg.micr0bu.ui.components
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.GpsFixed
import androidx.compose.material.icons.filled.GpsOff
import androidx.compose.material.icons.filled.Sensors
import androidx.compose.material.icons.filled.SensorsOff
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.filled.WifiOff
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
private val GreenActive = Color(0xFF4CAF50)
private val AmberAcquiring = Color(0xFFFFC107)
private val ErrorRed = Color(0xFFFF5252)
private val GrayInactive = Color(0xFF555E6A)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StatusTopBar(state: SensorUiState, mqttConnectionState: MqttConnectionState) {
TopAppBar(
title = {
Text(
text = "MicrOBU",
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
},
actions = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(end = 12.dp),
) {
if (state.isRecording) {
RecordingPulse()
Spacer(Modifier.width(10.dp))
}
StatusIcon(
activeIcon = Icons.Default.GpsFixed,
inactiveIcon = Icons.Default.GpsOff,
tint = when {
state.gnss != null -> GreenActive
state.locationPermissionGranted -> AmberAcquiring
else -> GrayInactive
},
description = "GNSS",
)
Spacer(Modifier.width(8.dp))
StatusIcon(
activeIcon = Icons.Default.Sensors,
inactiveIcon = Icons.Default.SensorsOff,
tint = if (state.accel != null) GreenActive else GrayInactive,
description = "IMU",
)
Spacer(Modifier.width(8.dp))
ObuStatusIcon(mqttConnectionState)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
}
@Composable
private fun ObuStatusIcon(state: MqttConnectionState) {
val icon = when (state) {
MqttConnectionState.CONNECTED,
MqttConnectionState.CONNECTING -> Icons.Default.Wifi
MqttConnectionState.ERROR,
MqttConnectionState.DISCONNECTED -> Icons.Default.WifiOff
}
val tint = when (state) {
MqttConnectionState.CONNECTED -> GreenActive
MqttConnectionState.CONNECTING -> AmberAcquiring
MqttConnectionState.ERROR -> ErrorRed
MqttConnectionState.DISCONNECTED -> GrayInactive
}
// Pulse the icon while connecting so it's visually distinct from static states
val alpha = if (state == MqttConnectionState.CONNECTING) {
val transition = rememberInfiniteTransition(label = "obuConnect")
transition.animateFloat(
initialValue = 1f,
targetValue = 0.3f,
animationSpec = infiniteRepeatable(
animation = tween(800, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
label = "obuAlpha",
).value
} else {
1f
}
Icon(
imageVector = icon,
contentDescription = "OBU",
tint = tint.copy(alpha = alpha),
modifier = Modifier.size(20.dp),
)
}
@Composable
private fun StatusIcon(
activeIcon: ImageVector,
inactiveIcon: ImageVector,
tint: Color,
description: String,
) {
Icon(
imageVector = if (tint != GrayInactive) activeIcon else inactiveIcon,
contentDescription = description,
tint = tint,
modifier = Modifier.size(20.dp),
)
}
@Composable
private fun RecordingPulse() {
val transition = rememberInfiniteTransition(label = "rec")
val alpha by transition.animateFloat(
initialValue = 1f,
targetValue = 0.15f,
animationSpec = infiniteRepeatable(
animation = tween(600, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
label = "recAlpha",
)
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(Color(0xFFFF5252).copy(alpha = alpha)),
)
}
@@ -0,0 +1,119 @@
package com.hawhamburg.micr0bu.ui.navigation
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.FiberManualRecord
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Router
import androidx.compose.material.icons.filled.Sensors
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.sp
import androidx.compose.ui.Modifier
import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState
import com.hawhamburg.micr0bu.R
sealed class Screen(val route: String, val labelRes: Int) {
data object Dashboard : Screen("dashboard", R.string.nav_dashboard)
data object Sensors : Screen("sensors", R.string.nav_sensors)
data object Record : Screen("record", R.string.nav_record)
data object Log : Screen("log", R.string.nav_log)
data object Settings : Screen("settings", R.string.nav_settings)
data object Connection : Screen("connection", R.string.nav_connection)
data object Map : Screen("map", R.string.map_title)
data object MqttViewer : Screen("mqtt_viewer", R.string.nav_v2x)
// Phase A — Trip Recording
data object TripHistory : Screen("trip_history", R.string.nav_trips)
data object TripReview : Screen("trip_review/{tripId}", R.string.trip_review_title) {
fun route(tripId: Long) = "trip_review/$tripId"
}
data object SettingsLanguage : Screen("settings/language", R.string.settings_language)
data object SettingsAppearance : Screen("settings/appearance", R.string.settings_appearance)
data object SettingsSensors : Screen("settings/sensors", R.string.settings_sensors)
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 SettingsDeveloper : Screen("settings/developer", R.string.settings_developer)
data object SettingsAbout : Screen("settings/about", R.string.settings_about)
}
private val bottomNavItems = listOf(
Screen.Dashboard,
Screen.Sensors,
Screen.Record,
Screen.TripHistory,
Screen.MqttViewer,
Screen.Settings,
)
@Composable
fun BottomNavBar(navController: NavController) {
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
NavigationBar {
bottomNavItems.forEach { screen ->
NavigationBarItem(
selected = currentRoute == screen.route,
onClick = {
navController.navigate(screen.route) {
popUpTo(Screen.Dashboard.route) { saveState = true }
launchSingleTop = true
restoreState = true
}
},
icon = {
Icon(
imageVector = when (screen) {
Screen.Dashboard -> Icons.Default.Dashboard
Screen.Sensors -> Icons.Default.Sensors
Screen.Record -> Icons.Default.FiberManualRecord
Screen.TripHistory -> Icons.Default.History
Screen.MqttViewer -> Icons.Default.Router
Screen.Settings -> Icons.Default.Settings
else -> Icons.Default.Dashboard
},
contentDescription = stringResource(screen.labelRes),
)
},
label = { AutoSizeNavLabel(stringResource(screen.labelRes)) },
)
}
}
}
// Shrinks font until the label fits on one line — prevents wrapping on longer German labels.
@Composable
private fun AutoSizeNavLabel(text: String) {
var fontSize by remember(text) { mutableStateOf(12.sp) }
var readyToDraw by remember(text) { mutableStateOf(false) }
Text(
text = text,
fontSize = fontSize,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Clip,
modifier = Modifier.drawWithContent { if (readyToDraw) drawContent() },
onTextLayout = { result ->
if (result.hasVisualOverflow && fontSize > 8.sp) {
fontSize *= 0.85f
} else {
readyToDraw = true
}
},
)
}
@@ -0,0 +1,248 @@
package com.hawhamburg.micr0bu.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.LinkOff
import androidx.compose.material.icons.filled.Usb
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.transport.TransportType
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
private val UsbGreen = Color(0xFF4CAF50)
private val UsbGreenBg = Color(0xFF0D2B1A)
private val UsbAmber = Color(0xFFFFC107)
private val UsbAmberBg = Color(0xFF2B2000)
private val UsbGray = Color(0xFF8B949E)
private val UsbGrayBg = Color(0xFF1C1C1C)
@Composable
fun ConnectionSetupScreen(
viewModel: MqttViewModel = hiltViewModel(),
modifier: Modifier = Modifier,
) {
val connectionState by viewModel.connectionState.collectAsState()
val usbConnected by viewModel.usbConnected.collectAsState()
val detectedObuIp by viewModel.detectedObuIp.collectAsState()
val activeTransport by viewModel.activeTransport.collectAsState()
val mqttPrefs by viewModel.mqttPrefs.collectAsState()
val isConnected = connectionState == MqttConnectionState.CONNECTED
val isConnecting = connectionState == MqttConnectionState.CONNECTING
val isUsbMode = activeTransport == TransportType.USB_C
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
stringResource(R.string.conn_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
Text(
stringResource(R.string.conn_phase2_desc),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// ── USB-C Primary Card ─────────────────────────────────────────────
val usbContainerColor = when {
usbConnected && isConnected -> UsbGreenBg
usbConnected && isConnecting -> UsbAmberBg
else -> UsbGrayBg
}
Card(
colors = CardDefaults.cardColors(containerColor = usbContainerColor),
shape = RoundedCornerShape(12.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
// Card title row
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
Icons.Default.Usb,
contentDescription = null,
tint = if (usbConnected) UsbGreen else UsbGray,
modifier = Modifier.size(20.dp),
)
Text(
stringResource(R.string.conn_usb_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = if (usbConnected) UsbGreen else UsbGray,
)
Spacer(Modifier.weight(1f))
// Cable state pill
Surface(
color = if (usbConnected) UsbGreen.copy(alpha = 0.2f)
else UsbGray.copy(alpha = 0.2f),
shape = RoundedCornerShape(50),
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
if (usbConnected) Icons.Default.CheckCircle else Icons.Default.Warning,
null,
tint = if (usbConnected) UsbGreen else UsbAmber,
modifier = Modifier.size(12.dp),
)
Text(
text = if (usbConnected) stringResource(R.string.conn_usb_cable_ok)
else stringResource(R.string.conn_usb_cable_missing),
style = MaterialTheme.typography.labelSmall,
color = if (usbConnected) UsbGreen else UsbAmber,
fontWeight = FontWeight.SemiBold,
)
}
}
}
Spacer(Modifier.height(12.dp))
// OBU IP row
InfoRow(
label = stringResource(R.string.conn_usb_obu_ip),
value = when {
mqttPrefs.usbAutoDetect && detectedObuIp != null -> detectedObuIp!!
mqttPrefs.usbAutoDetect -> stringResource(R.string.conn_usb_detecting)
else -> mqttPrefs.usbIpOverride
},
)
Spacer(Modifier.height(4.dp))
InfoRow(
label = stringResource(R.string.conn_usb_mode),
value = if (mqttPrefs.usbAutoDetect) stringResource(R.string.conn_usb_auto)
else stringResource(R.string.conn_usb_manual),
)
Spacer(Modifier.height(12.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f))
Spacer(Modifier.height(12.dp))
// MQTT status row
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
val (mqttLabel, mqttColor) = when (connectionState) {
MqttConnectionState.CONNECTED -> stringResource(R.string.dash_mqtt_connected) to UsbGreen
MqttConnectionState.CONNECTING -> stringResource(R.string.dash_mqtt_connecting) to UsbAmber
MqttConnectionState.ERROR -> stringResource(R.string.dash_mqtt_error) to Color(0xFFFF5252)
MqttConnectionState.DISCONNECTED -> stringResource(R.string.dash_tap_to_connect) to UsbGray
}
Icon(
if (isConnected) Icons.Default.Link else Icons.Default.LinkOff,
null,
tint = mqttColor,
modifier = Modifier.size(14.dp),
)
Text(mqttLabel, style = MaterialTheme.typography.bodySmall, color = mqttColor)
}
Spacer(Modifier.height(12.dp))
// Connect / Disconnect button
if (isConnected || isConnecting) {
OutlinedButton(
onClick = { viewModel.disconnect() },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = Color(0xFFFF5252),
),
) {
Icon(Icons.Default.LinkOff, null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.conn_disconnect))
}
} else {
Button(
onClick = { viewModel.connect() },
modifier = Modifier.fillMaxWidth(),
enabled = !isUsbMode || usbConnected || !mqttPrefs.usbAutoDetect,
) {
Icon(Icons.Default.Link, null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.conn_connect_mqtt))
}
}
if (!usbConnected && mqttPrefs.usbAutoDetect) {
Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.conn_usb_no_cable_hint),
style = MaterialTheme.typography.bodySmall,
color = UsbAmber.copy(alpha = 0.8f),
)
}
}
}
}
}
@Composable
private fun InfoRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
Text(
value,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
@@ -0,0 +1,434 @@
package com.hawhamburg.micr0bu.ui.screens
import android.content.Intent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Launch
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.BluetoothDisabled
import androidx.compose.material.icons.filled.Usb
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.filled.WifiOff
import androidx.compose.material.icons.filled.FiberManualRecord
import androidx.compose.material.icons.filled.GpsFixed
import androidx.compose.material.icons.filled.GpsOff
import androidx.compose.material.icons.filled.Map
import androidx.compose.material.icons.filled.Sensors
import androidx.compose.material.icons.filled.SensorsOff
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.transport.TransportType
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
import kotlin.math.sqrt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DashboardScreen(
state: SensorUiState,
mqttConnectionState: MqttConnectionState,
activeTransport: TransportType = TransportType.USB_C,
usbCableConnected: Boolean = false,
obuStationTypeWarning: Boolean = false,
onNavigateToConnection: () -> Unit,
onNavigateToSensors: () -> Unit,
onNavigateToMap: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val gnssSheetVisible = remember { mutableStateOf(false) }
// GNSS location bottom sheet
if (gnssSheetVisible.value) {
ModalBottomSheet(
onDismissRequest = { gnssSheetVisible.value = false },
sheetState = sheetState,
) {
Text(
text = stringResource(R.string.gnss_sheet_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
state.gnss?.let { gnss ->
Text(
text = "%.5f°, %.5f°".format(gnss.latitude, gnss.longitude),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
)
}
HorizontalDivider()
// Open in external maps app — Android routes to user's default/preferred app
ListItem(
headlineContent = { Text(stringResource(R.string.gnss_open_maps)) },
supportingContent = { Text(stringResource(R.string.gnss_open_maps_sub)) },
leadingContent = {
Icon(Icons.AutoMirrored.Filled.Launch, null, tint = MaterialTheme.colorScheme.primary)
},
modifier = Modifier.clickable(enabled = state.gnss != null) {
state.gnss?.let { gnss ->
val uri =
"geo:${gnss.latitude},${gnss.longitude}?q=${gnss.latitude},${gnss.longitude}".toUri()
context.startActivity(Intent(Intent.ACTION_VIEW, uri))
}
gnssSheetVisible.value = false
},
)
HorizontalDivider()
// In-app OSMDroid map
ListItem(
headlineContent = { Text(stringResource(R.string.gnss_view_inapp)) },
supportingContent = { Text(stringResource(R.string.gnss_view_inapp_sub)) },
leadingContent = {
Icon(Icons.Default.Map, null, tint = MaterialTheme.colorScheme.primary)
},
modifier = Modifier.clickable {
gnssSheetVisible.value = false
onNavigateToMap()
},
)
Spacer(Modifier.height(24.dp))
}
}
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(R.string.app_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Recording banner
if (state.isRecording) {
androidx.compose.material3.Card(
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = Color(0xFF3A1010)
),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
Icons.Default.FiberManualRecord, null,
tint = Color(0xFFFF5252),
modifier = Modifier.size(14.dp),
)
Text(
"${stringResource(R.string.dash_recording)} ${formatDuration(state.recordingDurationSeconds)}",
style = MaterialTheme.typography.labelLarge,
color = Color(0xFFFF5252),
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.weight(1f))
Text(
"${state.recordingSampleCount} ${stringResource(R.string.dash_samples)}",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFFF9090),
)
}
}
}
// ── stationType warning banner ────────────────────────────────────
if (obuStationTypeWarning) {
androidx.compose.material3.Card(
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = Color(0xFF3A2000)
),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
Icons.Default.Warning, null,
tint = Color(0xFFFFC107),
modifier = Modifier.size(16.dp),
)
Text(
stringResource(R.string.dash_station_type_warning),
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFFFC107),
)
}
}
}
// GNSS card — tapping opens the bottom sheet
StatusCard(
title = stringResource(R.string.dash_gnss),
active = state.gnss != null,
activeIcon = Icons.Default.GpsFixed,
inactiveIcon = Icons.Default.GpsOff,
summary = state.gnss?.let {
"%.5f, %.5f | %.1f m/s".format(it.latitude, it.longitude, it.speedMs)
} ?: if (state.locationPermissionGranted) stringResource(R.string.dash_acquiring_fix)
else stringResource(R.string.dash_permission_required),
onClick = { gnssSheetVisible.value = true },
)
// IMU card — tapping navigates to Sensors screen
StatusCard(
title = stringResource(R.string.dash_imu),
active = state.accel != null,
activeIcon = Icons.Default.Sensors,
inactiveIcon = Icons.Default.SensorsOff,
summary = state.accel?.let {
val mag = sqrt(it.x * it.x + it.y * it.y + it.z * it.z)
"Accel magnitude: %.2f m/s²".format(mag)
} ?: stringResource(R.string.dash_initialising),
onClick = onNavigateToSensors,
)
val mqttConnected = mqttConnectionState == MqttConnectionState.CONNECTED
val transportIcon = when (activeTransport) {
TransportType.USB_C -> Icons.Default.Usb
TransportType.WIFI -> Icons.Default.Wifi
TransportType.BLUETOOTH -> Icons.Default.Bluetooth
}
val transportInactiveIcon = when (activeTransport) {
TransportType.USB_C -> Icons.Default.Usb
TransportType.WIFI -> Icons.Default.WifiOff
TransportType.BLUETOOTH -> Icons.Default.BluetoothDisabled
}
// OBU connection card with transport chip row
androidx.compose.material3.Card(
modifier = Modifier.fillMaxWidth().clickable { onNavigateToConnection() },
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
val tint = if (mqttConnected) Color(0xFF4CAF50)
else MaterialTheme.colorScheme.onSurfaceVariant
Icon(
if (mqttConnected) transportIcon else transportInactiveIcon,
null,
tint = tint,
modifier = Modifier.size(28.dp),
)
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(R.string.dash_obu_connection),
style = MaterialTheme.typography.labelLarge,
color = tint,
fontWeight = FontWeight.SemiBold,
)
Text(
text = when (mqttConnectionState) {
MqttConnectionState.CONNECTED -> stringResource(R.string.dash_mqtt_connected)
MqttConnectionState.CONNECTING -> stringResource(R.string.dash_mqtt_connecting)
MqttConnectionState.ERROR -> stringResource(R.string.dash_mqtt_error)
MqttConnectionState.DISCONNECTED -> stringResource(R.string.dash_tap_to_connect)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.height(10.dp))
// Transport chip row — USB-C / Wi-Fi / BT with active highlighted
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
TransportChip(
label = stringResource(R.string.dash_transport_usbc),
icon = Icons.Default.Usb,
active = activeTransport == TransportType.USB_C,
hasCable = usbCableConnected,
)
TransportChip(
label = stringResource(R.string.dash_transport_wifi),
icon = Icons.Default.Wifi,
active = activeTransport == TransportType.WIFI,
)
TransportChip(
label = stringResource(R.string.dash_transport_bt),
icon = Icons.Default.Bluetooth,
active = activeTransport == TransportType.BLUETOOTH,
dimmed = true, // Phase 03 — not yet available
)
}
}
}
Spacer(Modifier.height(4.dp))
if (state.pressureHpa != null)
QuickStatRow(stringResource(R.string.stat_pressure), "%.1f hPa".format(state.pressureHpa))
if (state.gnss != null) {
QuickStatRow(stringResource(R.string.stat_altitude), "%.1f m".format(state.gnss.altitude))
QuickStatRow(stringResource(R.string.stat_heading), "%.0f°".format(state.gnss.bearingDeg))
QuickStatRow(stringResource(R.string.stat_accuracy), "±%.1f m".format(state.gnss.accuracyM))
}
}
}
fun formatDuration(totalSeconds: Long): String {
val h = totalSeconds / 3600
val m = (totalSeconds % 3600) / 60
val s = totalSeconds % 60
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%02d:%02d".format(m, s)
}
@Composable
private fun StatusCard(
title: String,
active: Boolean,
activeIcon: ImageVector,
inactiveIcon: ImageVector,
summary: String,
badge: String? = null,
onClick: (() -> Unit)? = null,
) {
val tint = if (active) Color(0xFF4CAF50) else MaterialTheme.colorScheme.onSurfaceVariant
val mod = if (onClick != null) Modifier.fillMaxWidth().clickable { onClick() }
else Modifier.fillMaxWidth()
androidx.compose.material3.Card(
modifier = mod,
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(if (active) activeIcon else inactiveIcon, null, tint = tint, modifier = Modifier.size(28.dp))
Column(modifier = Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.labelLarge, color = tint, fontWeight = FontWeight.SemiBold)
Text(summary, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (badge != null) {
androidx.compose.material3.Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = androidx.compose.foundation.shape.RoundedCornerShape(50),
) {
Text(
badge,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
}
}
}
/**
* Small pill showing a transport option (USB-C / Wi-Fi / BT).
* [active] → primary container colour; [dimmed] → grayed out (Phase 03 placeholder).
*/
@Composable
private fun TransportChip(
label: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
active: Boolean,
hasCable: Boolean = false,
dimmed: Boolean = false,
) {
val containerColor = when {
active -> MaterialTheme.colorScheme.primaryContainer
dimmed -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
else -> MaterialTheme.colorScheme.surfaceVariant
}
val contentColor = when {
active -> MaterialTheme.colorScheme.onPrimaryContainer
dimmed -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f)
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
androidx.compose.material3.Surface(
color = containerColor,
shape = androidx.compose.foundation.shape.RoundedCornerShape(50),
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(icon, null, tint = contentColor, modifier = Modifier.size(12.dp))
Text(
label,
style = MaterialTheme.typography.labelSmall,
color = contentColor,
fontWeight = if (active) FontWeight.Bold else FontWeight.Normal,
)
// Cable-presence dot for USB-C
if (hasCable && !active) {
Icon(
Icons.Default.FiberManualRecord, null,
tint = Color(0xFF4CAF50),
modifier = Modifier.size(6.dp),
)
}
}
}
}
@Composable
private fun QuickStatRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
}
}
@@ -0,0 +1,138 @@
package com.hawhamburg.micr0bu.ui.screens
import android.content.Context
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.GpsOff
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.GnssReading
import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker
@Composable
fun MapScreen(gnss: GnssReading?, modifier: Modifier = Modifier) {
val context = LocalContext.current
if (gnss == null) {
NoFixPlaceholder(modifier)
return
}
val geoPoint = remember(gnss.latitude, gnss.longitude) {
GeoPoint(gnss.latitude, gnss.longitude)
}
// Hold a reference so we can manage the OSMDroid lifecycle
val mapViewRef = remember { mutableStateOf<MapView?>(null) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> mapViewRef.value?.onResume()
Lifecycle.Event.ON_PAUSE -> mapViewRef.value?.onPause()
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
mapViewRef.value?.onDetach()
}
}
Column(modifier = modifier.fillMaxSize()) {
// Coordinate strip at the top
Text(
text = "%.6f°, %.6f° · ±%.0f m".format(
gnss.latitude, gnss.longitude, gnss.accuracyM
),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
AndroidView(
factory = { ctx ->
initOsm(ctx)
MapView(ctx).apply {
setTileSource(TileSourceFactory.MAPNIK)
setMultiTouchControls(true)
controller.setZoom(17.0)
controller.setCenter(geoPoint)
mapViewRef.value = this
}
},
update = { mv ->
mv.overlays.clear()
val marker = Marker(mv).apply {
position = geoPoint
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
title = stringLabel(context, gnss)
}
mv.overlays.add(marker)
mv.controller.animateTo(geoPoint)
mv.invalidate()
},
modifier = Modifier.fillMaxSize(),
)
}
}
@Composable
private fun NoFixPlaceholder(modifier: Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.GpsOff,
contentDescription = null,
modifier = Modifier.size(56.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
Text(
stringResource(R.string.gnss_no_fix),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
private fun initOsm(context: Context) {
Configuration.getInstance().apply {
load(context, context.getSharedPreferences("osmdroid", Context.MODE_PRIVATE))
userAgentValue = context.packageName
}
}
private fun stringLabel(context: Context, gnss: GnssReading): String =
context.getString(R.string.map_location_label) +
"\n%.5f°, %.5f°".format(gnss.latitude, gnss.longitude)
@@ -0,0 +1,636 @@
package com.hawhamburg.micr0bu.ui.screens
import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.remember
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.LinkOff
import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.VerticalAlignBottom
import androidx.compose.material3.Badge
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
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.viewmodel.MqttViewModel
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
private val ConnectedGreen = Color(0xFF4CAF50)
private val ConnectedGreenBg = Color(0xFF1B3A1B)
private val ConnectingAmber = Color(0xFFFFC107)
private val ConnectingAmberBg = Color(0xFF3A2D00)
private val ErrorRed = Color(0xFFFF5252)
private val ErrorRedBg = Color(0xFF3A1010)
private val DisconnectedGray = Color(0xFF8B949E)
private val DisconnectedGrayBg = Color(0xFF1C2733)
private val DenmRed = Color(0xFFE53935)
private val DenmRedBg = Color(0xFF3A0A0A)
private val DenmAmber = Color(0xFFFFC107)
@Composable
fun MqttTopicViewerScreen(
viewModel: MqttViewModel = hiltViewModel(),
) {
val connectionState by viewModel.connectionState.collectAsState()
val topicMessages by viewModel.topicMessages.collectAsState()
val selectedTopic by viewModel.selectedTopic.collectAsState()
val autoScroll by viewModel.autoScroll.collectAsState()
val denmActive by viewModel.denmActive.collectAsState()
val lastDenmPayload by viewModel.lastDenmPayload.collectAsState()
// Sort: sys/ topics first (heartbeat/health), then alphabetical
val sortedTopics = topicMessages.keys.sortedWith(
compareBy({ if (it.startsWith("sys/")) 0 else 1 }, { it })
)
BackHandler(enabled = selectedTopic != null) {
viewModel.selectTopic(null)
}
val isConnected = connectionState == MqttConnectionState.CONNECTED
val isConnecting = connectionState == MqttConnectionState.CONNECTING
Column(modifier = Modifier.fillMaxSize()) {
// ── Inline header — replaces the removed inner TopAppBar ──────────────
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 4.dp, end = 4.dp, top = 4.dp, bottom = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (selectedTopic != null) {
IconButton(onClick = { viewModel.selectTopic(null) }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
Text(
text = selectedTopic!!.substringAfterLast('/'),
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = { viewModel.setAutoScroll(!autoScroll) },
colors = IconButtonDefaults.iconButtonColors(
contentColor = if (autoScroll) ConnectedGreen
else MaterialTheme.colorScheme.onSurfaceVariant,
),
) {
Icon(Icons.Default.VerticalAlignBottom,
contentDescription = stringResource(R.string.mqtt_auto_scroll))
}
} else {
Spacer(Modifier.weight(1f))
}
ConnectionChip(connectionState)
Spacer(Modifier.width(2.dp))
IconButton(
onClick = { if (isConnected || isConnecting) viewModel.disconnect() else viewModel.connect() },
colors = IconButtonDefaults.iconButtonColors(
contentColor = if (isConnected) ErrorRed else ConnectedGreen,
),
) {
Icon(
imageVector = if (isConnected || isConnecting) Icons.Default.LinkOff
else Icons.Default.Link,
contentDescription = if (isConnected || isConnecting)
stringResource(R.string.mqtt_disconnect)
else
stringResource(R.string.mqtt_connect),
)
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f))
if (selectedTopic == null) {
TopicListPane(
topics = sortedTopics,
topicMessages = topicMessages,
connectionState = connectionState,
denmActive = denmActive,
lastDenmPayload = lastDenmPayload,
onSelectTopic = { viewModel.selectTopic(it) },
onSendDenm = { viewModel.sendDenm() },
onStopDenm = { viewModel.stopDenm() },
)
} else {
MessageDetailPane(
messages = topicMessages[selectedTopic] ?: emptyList(),
autoScroll = autoScroll,
)
}
}
}
// ── Topic list ────────────────────────────────────────────────────────────────
@Composable
private fun TopicListPane(
topics: List<String>,
topicMessages: Map<String, List<MqttMessage>>,
connectionState: MqttConnectionState,
denmActive: Boolean,
lastDenmPayload: String?,
onSelectTopic: (String) -> Unit,
onSendDenm: () -> Unit,
onStopDenm: () -> Unit,
) {
val isConnected = connectionState == MqttConnectionState.CONNECTED
Column(modifier = Modifier.fillMaxSize()) {
// ── DENM TX Control card ────────────────────────────────────────────
DenmTxCard(
isConnected = isConnected,
denmActive = denmActive,
lastDenmPayload = lastDenmPayload,
onSendDenm = onSendDenm,
onStopDenm = onStopDenm,
)
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
// ── Topic rows ──────────────────────────────────────────────────────
if (topics.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
stringResource(R.string.mqtt_no_topics),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(6.dp))
Text(
stringResource(R.string.mqtt_no_topics_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
}
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(topics, key = { it }) { topic ->
val msgs = topicMessages[topic] ?: emptyList()
val lastMsg = msgs.lastOrNull()
TopicRow(
topic = topic,
messageCount = msgs.size,
lastTimestamp = lastMsg?.timestamp,
onClick = { onSelectTopic(topic) },
)
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
}
}
}
}
}
@Composable
private fun TopicRow(
topic: String,
messageCount: Int,
lastTimestamp: Long?,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val dotColor = if (topic.startsWith("sys/")) ConnectedGreen
else MaterialTheme.colorScheme.primary
Icon(
Icons.Default.Circle,
contentDescription = null,
tint = dotColor,
modifier = Modifier.size(8.dp),
)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = topic,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (lastTimestamp != null) {
Text(
text = timeFormat.format(Date(lastTimestamp)),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.width(8.dp))
Badge(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
) {
Text(
text = messageCount.toString(),
style = MaterialTheme.typography.labelSmall,
)
}
}
}
// ── Message detail ────────────────────────────────────────────────────────────
@Composable
private fun MessageDetailPane(
messages: List<MqttMessage>,
autoScroll: Boolean,
) {
if (messages.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
stringResource(R.string.mqtt_no_messages),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
return
}
val reversed = remember(messages) { messages.reversed() }
val listState = rememberLazyListState()
LaunchedEffect(messages.size) {
if (autoScroll) listState.scrollToItem(0)
}
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
items(reversed, key = { "${it.topic}-${it.timestamp}" }) { msg ->
MessageRow(msg)
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 ||
(msg.direction == MessageDirection.RX && msg.topic.contains("denm", ignoreCase = true))
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = timeFormat.format(Date(msg.timestamp)),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
if (showBadge) DirectionBadge(msg.direction)
}
Spacer(Modifier.height(4.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(6.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
) {
Text(
text = prettyPrintJson(msg.payload),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 16.sp,
),
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(8.dp),
)
}
}
}
// ── DENM TX card ──────────────────────────────────────────────────────────────
@Composable
private fun DenmTxCard(
isConnected: Boolean,
denmActive: Boolean,
lastDenmPayload: String?,
onSendDenm: () -> Unit,
onStopDenm: () -> Unit,
) {
var showLastTx by rememberSaveable { mutableStateOf(false) }
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
colors = CardDefaults.cardColors(
containerColor = if (denmActive) DenmRedBg
else MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(modifier = Modifier.padding(12.dp)) {
// Card header row
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
Icons.Default.NotificationsActive,
contentDescription = null,
tint = if (denmActive) DenmRed
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp),
)
Text(
stringResource(R.string.mqtt_denm_tx_title),
style = MaterialTheme.typography.labelLarge,
color = if (denmActive) DenmRed
else MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.weight(1f))
if (lastDenmPayload != null) {
Text(
text = if (showLastTx) stringResource(R.string.mqtt_denm_hide)
else stringResource(R.string.mqtt_denm_show),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable { showLastTx = !showLastTx },
)
}
}
Spacer(Modifier.height(6.dp))
// Use-case label
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Surface(
color = if (denmActive) DenmRed.copy(alpha = 0.2f)
else MaterialTheme.colorScheme.outline.copy(alpha = 0.15f),
shape = RoundedCornerShape(4.dp),
) {
Text(
"hln-sv",
style = MaterialTheme.typography.labelSmall,
color = if (denmActive) DenmRed
else MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
Text(
stringResource(R.string.mqtt_denm_use_case_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(10.dp))
// Body
if (!isConnected) {
Text(
stringResource(R.string.mqtt_denm_not_connected),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else if (denmActive) {
// Active state — pulsing status + Stop button
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(bottom = 8.dp),
) {
Icon(
Icons.Default.Circle,
contentDescription = null,
tint = DenmRed,
modifier = Modifier.size(8.dp),
)
Text(
stringResource(R.string.mqtt_denm_active),
style = MaterialTheme.typography.bodySmall,
color = DenmRed,
)
}
Button(
onClick = onStopDenm,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = DenmRed),
) {
Text(stringResource(R.string.mqtt_stop_denm))
}
} else {
// Idle state — Send button + disabled Stop
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = onSendDenm,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
Icon(Icons.AutoMirrored.Filled.Send, null, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.mqtt_send_denm))
}
OutlinedButton(
onClick = {},
modifier = Modifier.weight(1f),
enabled = false,
) {
Text(stringResource(R.string.mqtt_stop_denm))
}
}
Text(
stringResource(R.string.mqtt_denm_hint),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
}
// Collapsible last-TX payload
if (showLastTx && lastDenmPayload != null) {
Spacer(Modifier.height(8.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f))
Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.mqtt_last_tx),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
Spacer(Modifier.height(4.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.surface),
) {
Text(
text = prettyPrintJson(lastDenmPayload),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
lineHeight = 14.sp,
),
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(8.dp),
)
}
}
}
}
}
// ── Direction badge ───────────────────────────────────────────────────────────
@Composable
private fun DirectionBadge(direction: MessageDirection) {
val (text, fg, bg) = when (direction) {
MessageDirection.TX -> Triple("TX", DenmAmber, Color(0xFF3A2D00))
MessageDirection.RX -> Triple("RX", ConnectedGreen, ConnectedGreenBg)
}
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),
)
}
}
// ── Connection status chip ────────────────────────────────────────────────────
@Composable
private fun ConnectionChip(state: MqttConnectionState) {
val (label, fg, bg) = when (state) {
MqttConnectionState.CONNECTED -> Triple("CONNECTED", ConnectedGreen, ConnectedGreenBg)
MqttConnectionState.CONNECTING -> Triple("CONNECTING…", ConnectingAmber, ConnectingAmberBg)
MqttConnectionState.ERROR -> Triple("ERROR", ErrorRed, ErrorRedBg)
MqttConnectionState.DISCONNECTED -> Triple("OFFLINE", DisconnectedGray, DisconnectedGrayBg)
}
val animatedBg by animateColorAsState(bg, animationSpec = tween(400), label = "chipBg")
val animatedFg by animateColorAsState(fg, animationSpec = tween(400), label = "chipFg")
Surface(
color = animatedBg,
shape = RoundedCornerShape(50),
modifier = Modifier.height(28.dp),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(horizontal = 10.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = animatedFg,
fontWeight = FontWeight.SemiBold,
fontFamily = FontFamily.Monospace,
letterSpacing = 0.5.sp,
)
}
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
private fun prettyPrintJson(raw: String): String {
val trimmed = raw.trim()
return try {
when {
trimmed.startsWith("{") -> JSONObject(trimmed).toString(2)
trimmed.startsWith("[") -> JSONArray(trimmed).toString(2)
else -> raw
}
} catch (_: JSONException) {
raw
}
}
@@ -0,0 +1,234 @@
package com.hawhamburg.micr0bu.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
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.Button
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.service.TripServiceBus
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
@Composable
fun RecordingScreen(
state: SensorUiState,
mqttConnectionState: MqttConnectionState,
tripServiceState: TripServiceBus.State,
onToggleRecording: () -> Unit,
onOpenSessionLog: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(stringResource(R.string.rec_title), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
Text(
text = if (state.isRecording) formatDuration(state.recordingDurationSeconds) else "--:--",
style = MaterialTheme.typography.displayMedium,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
color = if (state.isRecording) Color(0xFFFF5252) else MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Text(
text = if (state.isRecording)
stringResource(R.string.rec_samples_captured, state.recordingSampleCount)
else
stringResource(R.string.rec_ready),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
Button(
onClick = onToggleRecording,
shape = CircleShape,
modifier = Modifier.size(100.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (state.isRecording) Color(0xFF3A1010)
else MaterialTheme.colorScheme.primaryContainer,
),
) {
Icon(
imageVector = if (state.isRecording) Icons.Default.Stop else Icons.Default.FiberManualRecord,
contentDescription = if (state.isRecording) stringResource(R.string.rec_button_stop)
else stringResource(R.string.rec_button_rec),
tint = if (state.isRecording) Color(0xFFFF5252)
else MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(40.dp),
)
}
Text(
text = if (state.isRecording) stringResource(R.string.rec_button_stop)
else stringResource(R.string.rec_button_rec),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = if (state.isRecording) Color(0xFFFF5252) else MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(8.dp))
// ── Event Detection Counters ─────────────────────────────────────────
if (state.isRecording || tripServiceState.isRecording) {
Text(
stringResource(R.string.rec_events_detected),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.align(Alignment.Start),
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
EventCountBadge(
label = stringResource(R.string.rec_event_braking),
count = tripServiceState.brakingCount,
color = Color(0xFFFF5252),
)
EventCountBadge(
label = stringResource(R.string.rec_event_turning),
count = tripServiceState.turningCount,
color = Color(0xFFFFB300),
)
EventCountBadge(
label = stringResource(R.string.rec_event_stopping),
count = tripServiceState.stoppingCount,
color = Color(0xFF42A5F5),
)
}
}
Spacer(Modifier.height(4.dp))
}
// ── CSV Session Log shortcut ─────────────────────────────────────────
if (!state.isRecording) {
OutlinedButton(
onClick = onOpenSessionLog,
modifier = Modifier.fillMaxWidth(),
) {
Icon(
Icons.Default.TableChart,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.rec_open_session_log))
}
}
// ── Active Streams ────────────────────────────────────────────────────
Text(
stringResource(R.string.rec_active_streams),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.align(Alignment.Start),
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
StreamRow(stringResource(R.string.stream_gnss), state.gnssEnabled && state.gnss != null)
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
StreamRow(stringResource(R.string.stream_accel), state.accelEnabled && state.accel != null)
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
StreamRow(stringResource(R.string.stream_gyro), state.gyroEnabled && state.gyro != null)
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
StreamRow(stringResource(R.string.stream_magnet), state.magnetEnabled && state.magnet != null)
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
StreamRow(stringResource(R.string.stream_baro), state.barometerEnabled && state.pressureHpa != null)
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
StreamRow(stringResource(R.string.stream_obu), mqttConnectionState == MqttConnectionState.CONNECTED)
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
StreamRow(stringResource(R.string.rec_stream_event_detection), tripServiceState.isRecording)
}
}
}
}
@Composable
private fun EventCountBadge(label: String, count: Int, color: Color) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = count.toString(),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
color = color,
)
Spacer(Modifier.height(2.dp))
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
@Composable
private fun StreamRow(label: String, active: Boolean) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp, horizontal = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(label, style = MaterialTheme.typography.bodyMedium)
Text(
text = if (active) "● LIVE" else "○ OFF",
style = MaterialTheme.typography.labelMedium,
fontFamily = FontFamily.Monospace,
color = if (active) Color(0xFF4CAF50) else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold,
)
}
}
@@ -0,0 +1,118 @@
package com.hawhamburg.micr0bu.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.GnssReading
import com.hawhamburg.micr0bu.data.ImuReading
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
@Composable
fun SensorScreen(state: SensorUiState, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(stringResource(R.string.sensor_monitor_title), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
GnssCard(state.gnss)
ImuCard(stringResource(R.string.sensor_accel), "m/s²", state.accel)
ImuCard(stringResource(R.string.sensor_gyro), "rad/s", state.gyro, available = state.hasGyroscope)
ImuCard(stringResource(R.string.sensor_magnet), "µT", state.magnet, available = state.hasMagnetometer)
BarometerCard(state.pressureHpa, state.hasBarometer)
}
}
@Composable
private fun GnssCard(gnss: GnssReading?) {
SensorCard(title = stringResource(R.string.sensor_gnss)) {
if (gnss == null) {
UnavailableRow(stringResource(R.string.sensor_waiting))
} else {
DataRow(stringResource(R.string.sensor_latitude), "%.7f°".format(gnss.latitude))
DataRow(stringResource(R.string.sensor_longitude), "%.7f°".format(gnss.longitude))
DataRow(stringResource(R.string.sensor_altitude), "%.1f m".format(gnss.altitude))
DataRow(stringResource(R.string.sensor_speed), "%.2f m/s (%.1f km/h)".format(gnss.speedMs, gnss.speedMs * 3.6f))
DataRow(stringResource(R.string.sensor_heading), "%.1f°".format(gnss.bearingDeg))
DataRow(stringResource(R.string.sensor_accuracy), "±%.1f m".format(gnss.accuracyM))
}
}
}
@Composable
private fun ImuCard(title: String, unit: String, reading: ImuReading?, available: Boolean = true) {
SensorCard(title = title) {
when {
!available -> UnavailableRow(stringResource(R.string.sensor_not_present))
reading == null -> UnavailableRow(stringResource(R.string.sensor_initialising))
else -> {
DataRow("X", "%.4f $unit".format(reading.x))
DataRow("Y", "%.4f $unit".format(reading.y))
DataRow("Z", "%.4f $unit".format(reading.z))
}
}
}
}
@Composable
private fun BarometerCard(pressureHpa: Float?, available: Boolean) {
SensorCard(title = stringResource(R.string.sensor_baro)) {
when {
!available -> UnavailableRow(stringResource(R.string.sensor_not_present))
pressureHpa == null -> UnavailableRow(stringResource(R.string.sensor_initialising))
else -> {
DataRow(stringResource(R.string.sensor_pressure), "%.2f hPa".format(pressureHpa))
val alt = 44330.0 * (1.0 - Math.pow((pressureHpa / 1013.25).toDouble(), 0.1903))
DataRow(stringResource(R.string.sensor_est_altitude), "%.1f m".format(alt))
}
}
}
}
@Composable
private fun SensorCard(title: String, content: @Composable () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(title, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary)
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
content()
}
}
}
@Composable
private fun DataRow(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurface)
}
}
@Composable
private fun UnavailableRow(message: String) {
Text(message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@@ -0,0 +1,163 @@
package com.hawhamburg.micr0bu.ui.screens
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
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.sessionFileName
import com.hawhamburg.micr0bu.data.shareSessionCsv
import com.hawhamburg.micr0bu.viewmodel.RecordingSession
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
@Composable
fun SessionLogScreen(
state: SensorUiState,
onDeleteSession: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
var pendingDelete by remember { mutableStateOf<RecordingSession?>(null) }
var pendingSave by remember { mutableStateOf<RecordingSession?>(null) }
val saveLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("text/csv")
) { 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
}
pendingDelete?.let { session ->
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text(stringResource(R.string.log_delete_dialog_title)) },
text = { Text(stringResource(R.string.log_delete_dialog_text, sdf.format(Date(session.startTime)))) },
confirmButton = {
TextButton(onClick = { onDeleteSession(session.id); pendingDelete = null }) {
Text(stringResource(R.string.log_delete_confirm), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) { Text(stringResource(R.string.log_cancel)) }
},
)
}
Column(modifier = modifier.fillMaxSize().padding(16.dp)) {
Text(stringResource(R.string.log_title), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(
if (state.sessions.size == 1) stringResource(R.string.log_stored_one, state.sessions.size)
else stringResource(R.string.log_stored_other, state.sessions.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
if (state.sessions.isEmpty()) {
EmptyState()
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
itemsIndexed(state.sessions, key = { _, s -> s.id }) { index, session ->
SessionCard(
session = session,
index = state.sessions.size - index,
onShare = { shareSessionCsv(context, session) },
onSaveToDevice = { pendingSave = session; saveLauncher.launch(sessionFileName(session)) },
onDelete = { pendingDelete = session },
)
}
}
}
}
}
@Composable
private fun SessionCard(
session: RecordingSession,
index: Int,
onShare: () -> Unit,
onSaveToDevice: () -> Unit,
onDelete: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Row(modifier = Modifier.padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(stringResource(R.string.log_session_number, index), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary)
Text(sdf.format(Date(session.startTime)), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(top = 4.dp)) {
StatChip("⏱ ${formatDuration(session.durationSeconds)}")
StatChip("📊 ${session.sampleCount} samples")
}
}
IconButton(onClick = onSaveToDevice) {
Icon(Icons.Default.Download, contentDescription = stringResource(R.string.log_save_cd), tint = MaterialTheme.colorScheme.secondary)
}
IconButton(onClick = onShare) {
Icon(Icons.Default.Share, contentDescription = stringResource(R.string.log_share_cd), tint = MaterialTheme.colorScheme.primary)
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.log_delete_cd), tint = MaterialTheme.colorScheme.error)
}
}
}
}
@Composable
private fun StatChip(text: String) {
Text(text, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@Composable
private fun EmptyState() {
Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Icon(Icons.Default.FolderOpen, null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(12.dp))
Text(stringResource(R.string.log_empty_title), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.log_empty_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@@ -0,0 +1,536 @@
package com.hawhamburg.micr0bu.ui.screens
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
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.viewmodel.SensorUiState
private data class LangOption(val tag: String, val flag: String, val labelRes: Int)
private val languages = listOf(
LangOption("en", "🇬🇧", R.string.lang_english),
LangOption("de", "🇩🇪", R.string.lang_german),
)
// ── Top-level settings menu ─────────────────────────────────────────────────
@Composable
fun SettingsScreen(
onOpenLanguage: () -> Unit,
onOpenAppearance: () -> Unit,
onOpenSensors: () -> Unit,
onOpenUnits: () -> Unit,
onOpenConnection: () -> Unit,
onOpenMqtt: () -> Unit,
onOpenDeveloper: () -> Unit,
onOpenAbout: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
stringResource(R.string.settings_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column {
MenuRow(stringResource(R.string.settings_language), onOpenLanguage)
RowDivider()
MenuRow(stringResource(R.string.settings_appearance), onOpenAppearance)
RowDivider()
MenuRow(stringResource(R.string.settings_sensors), onOpenSensors)
RowDivider()
MenuRow(stringResource(R.string.settings_units), onOpenUnits)
RowDivider()
MenuRow(stringResource(R.string.settings_connection), onOpenConnection)
RowDivider()
MenuRow(stringResource(R.string.settings_mqtt_section), onOpenMqtt)
RowDivider()
MenuRow(stringResource(R.string.settings_developer), onOpenDeveloper)
RowDivider()
MenuRow(stringResource(R.string.settings_about), onOpenAbout)
}
}
}
}
@Composable
private fun MenuRow(label: String, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(label, style = MaterialTheme.typography.bodyMedium)
Icon(
Icons.Default.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// ── Sub-screen shell ────────────────────────────────────────────────────────
@Composable
private fun SubScreen(title: String, onBack: () -> Unit, content: @Composable () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 4.dp),
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
}
content()
Spacer(Modifier.height(8.dp))
}
}
// ── Sub-screens ─────────────────────────────────────────────────────────────
@Composable
fun ConnectionSettingsScreen(
mqttPrefs: MqttPrefs,
onMqttPrefsChange: (MqttPrefs) -> Unit,
onBack: () -> Unit,
) {
SubScreen(stringResource(R.string.settings_connection), onBack) {
SectionCard {
// Active transport selector
Text(
stringResource(R.string.settings_usb_transport),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
Spacer(Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
val isUsb = mqttPrefs.activeTransport == com.hawhamburg.micr0bu.data.mqtt.MqttPrefs.TRANSPORT_USB_C
OutlinedButton(
onClick = { onMqttPrefsChange(mqttPrefs.copy(activeTransport = com.hawhamburg.micr0bu.data.mqtt.MqttPrefs.TRANSPORT_USB_C)) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (isUsb) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
contentColor = if (isUsb) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
),
) { Text(stringResource(R.string.settings_transport_usbc), fontWeight = if (isUsb) FontWeight.Bold else FontWeight.Normal) }
OutlinedButton(
onClick = { onMqttPrefsChange(mqttPrefs.copy(activeTransport = com.hawhamburg.micr0bu.data.mqtt.MqttPrefs.TRANSPORT_WIFI)) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (!isUsb) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
contentColor = if (!isUsb) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
),
) { Text(stringResource(R.string.settings_transport_wifi), fontWeight = if (!isUsb) FontWeight.Bold else FontWeight.Normal) }
}
}
SectionCard {
// USB-C auto-detect toggle
SettingToggleRow(
label = stringResource(R.string.settings_usb_auto_detect),
checked = mqttPrefs.usbAutoDetect,
onCheckedChange = { onMqttPrefsChange(mqttPrefs.copy(usbAutoDetect = it)) },
)
Divider()
// Manual IP override
Spacer(Modifier.height(4.dp))
var usbIp by remember { mutableStateOf(mqttPrefs.usbIpOverride) }
MqttTextField(
label = stringResource(R.string.settings_usb_manual_ip),
value = usbIp,
placeholder = com.hawhamburg.micr0bu.data.mqtt.MqttPrefs.DEFAULT_USB_IP,
keyboardType = KeyboardType.Uri,
onValueChange = { usbIp = it },
onFocusLost = { onMqttPrefsChange(mqttPrefs.copy(usbIpOverride = usbIp)) },
)
Spacer(Modifier.height(4.dp))
}
}
}
@Composable
fun LanguageSettingsScreen(onBack: () -> Unit) {
SubScreen(stringResource(R.string.settings_language), onBack) {
SectionCard { LanguageSection() }
}
}
@Composable
fun AppearanceSettingsScreen(
darkTheme: Boolean,
onDarkTheme: (Boolean) -> Unit,
onBack: () -> Unit,
) {
SubScreen(stringResource(R.string.settings_appearance), onBack) {
SectionCard {
SettingToggleRow(stringResource(R.string.settings_dark_theme), darkTheme, onDarkTheme)
}
}
}
@Composable
fun SensorsSettingsScreen(
state: SensorUiState,
onAccelEnabled: (Boolean) -> Unit,
onGyroEnabled: (Boolean) -> Unit,
onMagnetEnabled: (Boolean) -> Unit,
onBarometerEnabled: (Boolean) -> Unit,
onGnssEnabled: (Boolean) -> Unit,
onBack: () -> Unit,
) {
SubScreen(stringResource(R.string.settings_sensors), onBack) {
SectionCard {
SettingToggleRow(stringResource(R.string.settings_gnss), state.gnssEnabled, onGnssEnabled)
Divider()
SettingToggleRow(stringResource(R.string.settings_accel), state.accelEnabled, onAccelEnabled)
Divider()
SettingToggleRow(stringResource(R.string.settings_gyro), state.gyroEnabled, onGyroEnabled, enabled = state.hasGyroscope)
Divider()
SettingToggleRow(stringResource(R.string.settings_magnet), state.magnetEnabled, onMagnetEnabled, enabled = state.hasMagnetometer)
Divider()
SettingToggleRow(stringResource(R.string.settings_baro), state.barometerEnabled, onBarometerEnabled, enabled = state.hasBarometer)
}
}
}
@Composable
fun UnitsSettingsScreen(
useMetric: Boolean,
onUseMetric: (Boolean) -> Unit,
onBack: () -> Unit,
) {
SubScreen(stringResource(R.string.settings_units), onBack) {
SectionCard {
SettingToggleRow(stringResource(R.string.settings_metric), useMetric, onUseMetric)
}
}
}
@Composable
fun MqttSettingsScreen(
mqttPrefs: MqttPrefs,
onMqttPrefsChange: (MqttPrefs) -> Unit,
onBack: () -> Unit,
) {
SubScreen(stringResource(R.string.settings_mqtt_section), onBack) {
SectionCard {
MqttSettingsSection(prefs = mqttPrefs, onChange = onMqttPrefsChange)
}
}
}
@Composable
fun DeveloperSettingsScreen(
state: SensorUiState,
onDeveloperMode: (Boolean) -> Unit,
onBack: () -> Unit,
) {
SubScreen(stringResource(R.string.settings_developer), onBack) {
SectionCard {
SettingToggleRow(stringResource(R.string.settings_dev_mode), state.developerMode, onDeveloperMode)
if (state.developerMode) {
Divider()
DisabledRow(stringResource(R.string.settings_wifi), stringResource(R.string.settings_wifi_val))
}
}
}
}
@Composable
fun AboutSettingsScreen(onBack: () -> Unit) {
SubScreen(stringResource(R.string.settings_about), onBack) {
SectionCard {
DisabledRow(stringResource(R.string.settings_app_version), stringResource(R.string.settings_app_version_val))
Divider()
DisabledRow(stringResource(R.string.settings_platform), stringResource(R.string.settings_platform_val))
Divider()
DisabledRow(stringResource(R.string.settings_project), stringResource(R.string.settings_project_val))
}
}
}
// ── Internal components ─────────────────────────────────────────────────────
@Composable
private fun SectionCard(content: @Composable () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) {
content()
}
}
}
@Composable
private fun MqttSettingsSection(prefs: MqttPrefs, onChange: (MqttPrefs) -> 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) }
var portStr by remember(prefs.brokerPort) { mutableStateOf(prefs.brokerPort.toString()) }
var username by remember(prefs.username) { mutableStateOf(prefs.username) }
var password by remember(prefs.password) { mutableStateOf(prefs.password) }
var passwordVisible by remember { mutableStateOf(false) }
Spacer(Modifier.height(4.dp))
MqttTextField(
label = stringResource(R.string.settings_broker_ip),
value = ip,
placeholder = "192.168.3.202",
keyboardType = KeyboardType.Uri,
onValueChange = { ip = it },
onFocusLost = { onChange(prefs.copy(brokerIp = ip)) },
)
Spacer(Modifier.height(8.dp))
MqttTextField(
label = stringResource(R.string.settings_port),
value = portStr,
placeholder = "1883",
keyboardType = KeyboardType.Number,
onValueChange = { portStr = it.filter { c -> c.isDigit() }.take(5) },
onFocusLost = {
val port = portStr.toIntOrNull() ?: 1883
portStr = port.toString()
onChange(prefs.copy(brokerPort = port))
},
)
Spacer(Modifier.height(8.dp))
MqttTextField(
label = stringResource(R.string.settings_username),
value = username,
placeholder = stringResource(R.string.settings_optional),
onValueChange = { username = it },
onFocusLost = { onChange(prefs.copy(username = username)) },
)
Spacer(Modifier.height(8.dp))
MqttTextField(
label = stringResource(R.string.settings_password),
value = password,
placeholder = stringResource(R.string.settings_optional),
visualTransformation = if (passwordVisible) VisualTransformation.None
else PasswordVisualTransformation(),
onValueChange = { password = it },
onFocusLost = { onChange(prefs.copy(password = password)) },
trailingLabel = if (passwordVisible) stringResource(R.string.settings_hide)
else stringResource(R.string.settings_show),
onTrailingClick = { passwordVisible = !passwordVisible },
)
Spacer(Modifier.height(12.dp))
// Explicit save button — ensures credentials are persisted even if focus-lost
// events are swallowed by the soft keyboard or fast back navigation.
Button(
onClick = {
val port = portStr.toIntOrNull() ?: 1883
portStr = port.toString()
onChange(
prefs.copy(
brokerIp = ip,
brokerPort = port,
username = username,
password = password,
)
)
},
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.settings_save_broker))
}
Spacer(Modifier.height(4.dp))
}
@Composable
private fun MqttTextField(
label: String,
value: String,
placeholder: String = "",
keyboardType: KeyboardType = KeyboardType.Text,
visualTransformation: VisualTransformation = VisualTransformation.None,
trailingLabel: String? = null,
onTrailingClick: (() -> Unit)? = null,
onFocusLost: () -> Unit = {},
onValueChange: (String) -> Unit,
) {
Column {
Text(
label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(2.dp))
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { if (!it.isFocused) onFocusLost() },
placeholder = {
Text(
placeholder,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
fontFamily = FontFamily.Monospace,
)
},
textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
visualTransformation = visualTransformation,
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
singleLine = true,
trailingIcon = if (trailingLabel != null) {
{
Text(
trailingLabel,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(end = 8.dp)
.clickable { onTrailingClick?.invoke() },
)
}
} else null,
)
}
}
@Composable
private fun LanguageSection() {
var currentTag by remember {
mutableStateOf(
AppCompatDelegate.getApplicationLocales()
.let { if (it.isEmpty) "en" else it[0]?.language ?: "en" }
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
languages.forEach { lang ->
val selected = currentTag == lang.tag
OutlinedButton(
onClick = {
currentTag = lang.tag
AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(lang.tag))
},
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
),
) {
Text(
"${lang.flag} ${stringResource(lang.labelRes)}",
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
)
}
}
}
}
@Composable
private fun RowDivider() = HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
@Composable
private fun Divider() = HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
@Composable
private fun SettingToggleRow(
label: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
enabled: Boolean = true,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
label,
style = MaterialTheme.typography.bodyMedium,
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
)
Switch(checked = checked && enabled, onCheckedChange = onCheckedChange, enabled = enabled)
}
}
@Composable
private fun DisabledRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface)
}
}
@@ -0,0 +1,179 @@
package com.hawhamburg.micr0bu.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DirectionsBike
import androidx.compose.material.icons.filled.Map
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.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.db.RecordedTripEntity
import com.hawhamburg.micr0bu.viewmodel.TripRecordingViewModel
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val tripSdf = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault())
@Composable
fun TripHistoryScreen(
viewModel: TripRecordingViewModel,
onOpenTrip: (Long) -> Unit,
modifier: Modifier = Modifier,
) {
val trips by viewModel.trips.collectAsState(initial = emptyList())
var pendingDelete by remember { mutableStateOf<RecordedTripEntity?>(null) }
pendingDelete?.let { trip ->
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text(stringResource(R.string.trip_delete_title)) },
text = { Text(stringResource(R.string.trip_delete_text, tripSdf.format(Date(trip.startTime)))) },
confirmButton = {
TextButton(onClick = { viewModel.deleteTrip(trip.id); pendingDelete = null }) {
Text(stringResource(R.string.log_delete_confirm), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) {
Text(stringResource(R.string.log_cancel))
}
},
)
}
Column(modifier = modifier.fillMaxSize().padding(16.dp)) {
Text(
stringResource(R.string.trip_history_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
Text(
if (trips.size == 1)
stringResource(R.string.trip_history_count_one, trips.size)
else
stringResource(R.string.trip_history_count_other, trips.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
if (trips.isEmpty()) {
TripEmptyState()
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
itemsIndexed(trips, key = { _, t -> t.id }) { index, trip ->
TripCard(
trip = trip,
index = trips.size - index,
onOpen = { onOpenTrip(trip.id) },
onDelete = { pendingDelete = trip },
)
}
}
}
}
}
@Composable
private fun TripCard(
trip: RecordedTripEntity,
index: Int,
onOpen: () -> Unit,
onDelete: () -> Unit,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onOpen),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Row(
modifier = Modifier.padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
stringResource(R.string.trip_number, index),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
Text(
tripSdf.format(Date(trip.startTime)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
val durationSec = ((trip.endTime - trip.startTime) / 1000).coerceAtLeast(0)
TripStatChip("⏱ ${formatDuration(durationSec)}")
TripStatChip("📍 ${formatDistance(trip.distanceMetres)}")
TripStatChip("🚨 ${trip.eventCount} events")
}
}
IconButton(onClick = onOpen) {
Icon(Icons.Default.Map, contentDescription = stringResource(R.string.trip_review_title), tint = MaterialTheme.colorScheme.primary)
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.log_delete_cd), tint = MaterialTheme.colorScheme.error)
}
}
}
}
@Composable
private fun TripStatChip(text: String) {
Text(text, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@Composable
private fun TripEmptyState() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.DirectionsBike, null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
Text(stringResource(R.string.trip_empty_title), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.trip_empty_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
private fun formatDistance(metres: Float): String =
if (metres >= 1_000f) "%.1f km".format(metres / 1_000f)
else "%.0f m".format(metres)
@@ -0,0 +1,302 @@
package com.hawhamburg.micr0bu.ui.screens
import android.content.Context
import android.graphics.drawable.GradientDrawable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
import com.hawhamburg.micr0bu.viewmodel.TripRecordingViewModel
import kotlinx.coroutines.launch
import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polyline
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TripReviewScreen(
trip: RecordedTripEntity,
viewModel: TripRecordingViewModel,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
// Initialize osmdroid configuration once per composition so the tile
// provider is ready before MapView is constructed in the factory block.
initOsmReview(context)
LaunchedEffect(trip.id) { viewModel.loadTripEvents(trip.id) }
val events by viewModel.selectedTripEvents.collectAsState()
val gpsPoints = remember(trip.gpsTrackJson) { parseGpsTrack(trip.gpsTrackJson) }
var selectedEvent by remember { mutableStateOf<DetectedEventEntity?>(null) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
val mapViewRef = remember { mutableStateOf<MapView?>(null) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> mapViewRef.value?.onResume()
Lifecycle.Event.ON_PAUSE -> mapViewRef.value?.onPause()
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
mapViewRef.value?.onDetach()
}
}
Column(modifier = modifier.fillMaxSize()) {
// ── Header strip ──────────────────────────────────────────────────────
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
val durationSec = ((trip.endTime - trip.startTime) / 1000).coerceAtLeast(0)
Text(
"Trip · ${formatDuration(durationSec)} · ${formatDistanceReview(trip.distanceMetres)}",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
"🚨 ${events.count { it.type == "BRAKING" }} " +
"🔄 ${events.count { it.type == "TURNING" }} " +
"🛑 ${events.count { it.type == "STOPPING" }}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// ── Map ───────────────────────────────────────────────────────────────
// weight(1f) is required here — fillMaxSize() inside a Column receives
// an UNBOUNDED height constraint and resolves to 0 px (invisible map).
AndroidView(
factory = { ctx ->
MapView(ctx).apply {
setTileSource(TileSourceFactory.MAPNIK)
setMultiTouchControls(true)
mapViewRef.value = this
}
},
update = { mv ->
mv.overlays.clear()
// GPS polyline
if (gpsPoints.size >= 2) {
val polyline = Polyline(mv).apply {
setPoints(gpsPoints.map { GeoPoint(it.lat, it.lon) })
outlinePaint.color = Color(0xFF1565C0).toArgb()
outlinePaint.strokeWidth = 8f
}
mv.overlays.add(polyline)
}
// Event pins
events.forEach { event ->
val pinColor = when (event.type) {
"BRAKING" -> Color(0xFFFF5252)
"TURNING" -> Color(0xFFFFB300)
"STOPPING" -> Color(0xFF42A5F5)
else -> Color.Gray
}
val marker = Marker(mv).apply {
position = GeoPoint(event.latitude, event.longitude)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
title = "${event.type} (${event.confidence})"
setOnMarkerClickListener { _, _ ->
selectedEvent = event
scope.launch { sheetState.show() }
true
}
// Solid-circle pin in the event color
icon = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(pinColor.toArgb())
setSize(32, 32)
}
}
mv.overlays.add(marker)
}
// Auto-fit the camera to the track — deferred via post() so the
// MapView has been measured before zoomToBoundingBox is called.
// Calling it with width/height == 0 (before first layout) crashes osmdroid.
if (gpsPoints.isNotEmpty()) {
val geoPoints = gpsPoints.map { GeoPoint(it.lat, it.lon) }
mv.post {
if (mv.width > 0 && mv.height > 0) {
val box = BoundingBox.fromGeoPoints(geoPoints)
mv.zoomToBoundingBox(box.increaseByScale(1.15f), false)
} else {
mv.controller.setZoom(14.0)
}
mv.invalidate()
}
} else {
mv.post {
mv.controller.setZoom(14.0)
mv.invalidate()
}
}
},
modifier = Modifier.weight(1f),
)
}
// ── Event detail bottom sheet ─────────────────────────────────────────────
val ev = selectedEvent
if (ev != null) {
ModalBottomSheet(
onDismissRequest = { selectedEvent = null },
sheetState = sheetState,
dragHandle = { BottomSheetDefaults.DragHandle() },
) {
EventDetailSheet(event = ev, onDismiss = {
scope.launch { sheetState.hide() }.invokeOnCompletion { selectedEvent = null }
})
}
}
}
// ── Event detail sheet content ────────────────────────────────────────────────
@Composable
private fun EventDetailSheet(event: DetectedEventEntity, onDismiss: () -> Unit) {
// Created here (not as a top-level static field) so it always uses the
// current locale even if the user changes it while the app is running.
val sdf = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
val accentColor = when (event.type) {
"BRAKING" -> Color(0xFFFF5252)
"TURNING" -> Color(0xFFFFB300)
"STOPPING" -> Color(0xFF42A5F5)
else -> MaterialTheme.colorScheme.primary
}
Column(modifier = Modifier.padding(horizontal = 20.dp).padding(bottom = 32.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
event.type.replaceFirstChar { it.titlecase() },
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = accentColor,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = "Close")
}
}
Text(
"Confidence: ${event.confidence}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
sdf.format(Date(event.timestamp)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
HorizontalDivider()
Spacer(Modifier.height(12.dp))
EventDetailRow("Speed", "%.1f m/s".format(event.speedMps))
EventDetailRow("Peak accel", "%.2f m/s²".format(event.peakAccelMagnitude))
EventDetailRow("Peak gyro", "%.3f rad/s".format(event.peakGyroMagnitude))
EventDetailRow("Duration", "${event.durationMs} ms")
EventDetailRow("Location", "%.5f°, %.5f°".format(event.latitude, event.longitude))
}
}
@Composable
private fun EventDetailRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodyMedium, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Medium)
}
}
// ── GPS track parsing ─────────────────────────────────────────────────────────
private data class GpsPoint(val lat: Double, val lon: Double)
// Braces escaped (\{ \}) so they are treated as literals, not quantifiers.
private val gpsPointRegex = Regex("""\{"lat":([-\d.E]+),"lon":([-\d.E]+),"ts":\d+\}""")
private fun parseGpsTrack(json: String): List<GpsPoint> =
gpsPointRegex.findAll(json).mapNotNull { match ->
runCatching {
GpsPoint(
lat = match.groupValues[1].toDouble(),
lon = match.groupValues[2].toDouble(),
)
}.getOrNull()
}.toList()
// ── Helpers ───────────────────────────────────────────────────────────────────
private fun initOsmReview(context: Context) {
Configuration.getInstance().apply {
load(context, context.getSharedPreferences("osmdroid", Context.MODE_PRIVATE))
userAgentValue = context.packageName
}
}
private fun formatDistanceReview(metres: Float): String =
if (metres >= 1_000f) "%.1f km".format(metres / 1_000f)
else "%.0f m".format(metres)
@@ -0,0 +1,60 @@
package com.hawhamburg.micr0bu.ui.theme
import android.os.Build
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
private val DarkColors = darkColorScheme(
primary = Color(0xFF90CAF9),
onPrimary = Color(0xFF003258),
primaryContainer = Color(0xFF00487D),
onPrimaryContainer = Color(0xFFD1E4FF),
secondary = Color(0xFF4FC3F7),
background = Color(0xFF0D1117),
surface = Color(0xFF161B22),
surfaceVariant = Color(0xFF1C2733),
onBackground = Color(0xFFE6EDF3),
onSurface = Color(0xFFE6EDF3),
onSurfaceVariant = Color(0xFF8B949E),
error = Color(0xFFFF5252),
outline = Color(0xFF30363D),
)
private val LightColors = lightColorScheme(
primary = Color(0xFF1565C0),
onPrimary = Color.White,
primaryContainer = Color(0xFFD1E4FF),
onPrimaryContainer = Color(0xFF001D36),
secondary = Color(0xFF0288D1),
background = Color(0xFFF5F5F5),
surface = Color(0xFFFFFFFF),
surfaceVariant = Color(0xFFE8EAF6),
onBackground = Color(0xFF1A1C1E),
onSurface = Color(0xFF1A1C1E),
onSurfaceVariant = Color(0xFF44464F),
error = Color(0xFFB00020),
outline = Color(0xFFBDBEC7),
)
@Composable
fun MicrOBUTheme(
darkTheme: Boolean = true,
content: @Composable () -> Unit
) {
val context = LocalContext.current
val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
} else {
if (darkTheme) DarkColors else LightColors
}
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}
@@ -0,0 +1,196 @@
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.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.mqtt.MqttMessage
import com.hawhamburg.micr0bu.data.mqtt.MqttPreferences
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
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,
) : ViewModel() {
// ── MQTT connection & messages ────────────────────────────────────────────
val connectionState: StateFlow<MqttConnectionState> = repo.connectionState
private val _topicMessages = MutableStateFlow<Map<String, List<MqttMessage>>>(emptyMap())
val topicMessages: StateFlow<Map<String, List<MqttMessage>>> = _topicMessages.asStateFlow()
private val _selectedTopic = MutableStateFlow<String?>(null)
val selectedTopic: StateFlow<String?> = _selectedTopic.asStateFlow()
private val _autoScroll = MutableStateFlow(true)
val autoScroll: StateFlow<Boolean> = _autoScroll.asStateFlow()
// ── Transport & USB ───────────────────────────────────────────────────────
val activeTransport: StateFlow<TransportType> = repo.activeTransport
/** True when a 192.168.42.x USB-C tethering network is detected. */
val usbConnected: StateFlow<Boolean> = usbDetector.usbNetwork
.map { it != null }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
/** Auto-detected OBU gateway IP on the USB interface. */
val detectedObuIp: StateFlow<String?> = usbDetector.detectedGatewayIp
// ── Prefs ─────────────────────────────────────────────────────────────────
val mqttPrefs: StateFlow<MqttPrefs> = prefs.prefsFlow.stateIn(
viewModelScope,
SharingStarted.Eagerly,
MqttPrefs(),
)
// ── 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
* ETSI EN 302 637-2 Table 1. Null until the first obu_gnss message arrives.
*/
val obuStationType: StateFlow<Int?> = _obuStationType.asStateFlow()
/**
* True when the OBU has reported a stationType other than 2 (cyclist).
* Triggers a persistent warning banner — an incorrect stationType means this OBU will
* not be detected as a VRU at equipped intersections.
*/
val obuStationTypeWarning: StateFlow<Boolean> = _obuStationType
.map { it != null && it != 2 }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
// ── DENM transmission ─────────────────────────────────────────────────────
private val _denmActive = MutableStateFlow(false)
/** True while the DENM use case is actively broadcasting on the OBU. */
val denmActive: StateFlow<Boolean> = _denmActive.asStateFlow()
private val _lastDenmPayload = MutableStateFlow<String?>(null)
/** JSON string of the most recently transmitted DENM control message. */
val lastDenmPayload: StateFlow<String?> = _lastDenmPayload.asStateFlow()
/** Tracks which use case is currently active so Stop uses the same usecase field. */
private var activeDenmUseCase: String? = null
// ── Init ──────────────────────────────────────────────────────────────────
init {
viewModelScope.launch {
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)
.optJSONObject("own_info")
?.optInt("stationType", -1) ?: -1
if (stType >= 0) _obuStationType.value = stType
}
}
}
}
}
// ── Actions ───────────────────────────────────────────────────────────────
fun connect() = repo.connect()
fun disconnect() = repo.disconnect()
fun selectTopic(topic: String?) { _selectedTopic.value = topic }
fun setAutoScroll(enabled: Boolean) { _autoScroll.value = enabled }
fun updatePrefs(newPrefs: MqttPrefs) {
viewModelScope.launch { prefs.update(newPrefs) }
}
// ── DENM actions ──────────────────────────────────────────────────────────
/**
* 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.
*/
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)
}
/**
* Publish a uca-denmctrl deactivate message (retained).
* Uses the same usecase string as the activate call so the OBU phases out the correct DENM.
*/
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)
}
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
override fun onCleared() {
super.onCleared()
repo.disconnect()
}
}
@@ -0,0 +1,293 @@
package com.hawhamburg.micr0bu.viewmodel
import android.app.Application
import android.hardware.Sensor
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.hawhamburg.micr0bu.data.GnssReading
import com.hawhamburg.micr0bu.data.ImuReading
import com.hawhamburg.micr0bu.data.SensorRepository
import com.hawhamburg.micr0bu.data.db.AppDatabase
import com.hawhamburg.micr0bu.data.db.SessionEntity
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.launch
import java.io.BufferedWriter
import java.io.File
import java.io.FileWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID
// UI model (no Room annotations)
data class RecordingSession(
val id: String,
val startTime: Long,
val endTime: Long,
val durationSeconds: Long,
val sampleCount: Int,
)
data class SensorUiState(
// Sensor readings
val gnss: GnssReading? = null,
val accel: ImuReading? = null,
val gyro: ImuReading? = null,
val magnet: ImuReading? = null,
val pressureHpa: Float? = null,
// Permissions & availability
val locationPermissionGranted: Boolean = false,
val hasGyroscope: Boolean = true,
val hasMagnetometer: Boolean = true,
val hasBarometer: Boolean = true,
// OBU connection (stub)
val obuConnected: Boolean = false,
val obuDeviceName: String? = null,
// Recording
val isRecording: Boolean = false,
val recordingStartTime: Long? = null,
val recordingDurationSeconds: Long = 0,
val recordingSampleCount: Int = 0,
val sessions: List<RecordingSession> = emptyList(),
// Settings
val accelEnabled: Boolean = true,
val gyroEnabled: Boolean = true,
val magnetEnabled: Boolean = true,
val barometerEnabled: Boolean = true,
val gnssEnabled: Boolean = true,
val developerMode: Boolean = true,
val useMetric: Boolean = true,
val darkTheme: Boolean = true,
)
private fun SessionEntity.toUiModel() = RecordingSession(
id = id,
startTime = startTime,
endTime = endTime,
durationSeconds = durationSeconds,
sampleCount = sampleCount,
)
class SensorViewModel(application: Application) : AndroidViewModel(application) {
private val repo = SensorRepository(application)
private val dao = AppDatabase.getInstance(application).sessionDao()
private val _state = MutableStateFlow(
SensorUiState(
hasGyroscope = repo.hasSensor(Sensor.TYPE_GYROSCOPE),
hasMagnetometer = repo.hasSensor(Sensor.TYPE_MAGNETIC_FIELD),
hasBarometer = repo.hasSensor(Sensor.TYPE_PRESSURE),
)
)
val state: StateFlow<SensorUiState> = _state.asStateFlow()
private var recordingTimerJob: Job? = null
private var recordingSessionId: String = ""
private var csvWriter: BufferedWriter? = null
private val isoFmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
init {
viewModelScope.launch {
dao.getAllSessions().catch { }.collect { entities ->
_state.value = _state.value.copy(sessions = entities.map { it.toUiModel() })
}
}
}
// ── Permissions ───────────────────────────────────────────────────────────
fun onLocationPermissionGranted() {
_state.value = _state.value.copy(locationPermissionGranted = true)
startGnss()
}
// ── Sensor streams ────────────────────────────────────────────────────────
fun startImuStreams() {
viewModelScope.launch {
repo.accelerometerFlow().catch { }.collect { r ->
if (_state.value.accelEnabled) {
_state.value = _state.value.copy(accel = r)
if (_state.value.isRecording) {
_state.value = _state.value.copy(
recordingSampleCount = _state.value.recordingSampleCount + 1
)
writeImuRow("accel", r)
}
}
}
}
viewModelScope.launch {
repo.gyroscopeFlow().catch { }.collect { r ->
if (_state.value.gyroEnabled) {
_state.value = _state.value.copy(gyro = r)
if (_state.value.isRecording) writeImuRow("gyro", r)
}
}
}
viewModelScope.launch {
repo.magnetometerFlow().catch { }.collect { r ->
if (_state.value.magnetEnabled) {
_state.value = _state.value.copy(magnet = r)
if (_state.value.isRecording) writeImuRow("magnet", r)
}
}
}
viewModelScope.launch {
repo.barometerFlow().catch { }.collect { hpa ->
if (_state.value.barometerEnabled) {
_state.value = _state.value.copy(pressureHpa = hpa)
if (_state.value.isRecording) writeBaroRow(hpa)
}
}
}
}
private fun startGnss() {
viewModelScope.launch {
repo.gnssFlow().catch { }.collect { r ->
if (_state.value.gnssEnabled) {
_state.value = _state.value.copy(gnss = r)
if (_state.value.isRecording) {
_state.value = _state.value.copy(
recordingSampleCount = _state.value.recordingSampleCount + 1
)
writeGnssRow(r)
}
}
}
}
}
// ── Recording ─────────────────────────────────────────────────────────────
fun toggleRecording() {
if (_state.value.isRecording) stopRecording() else startRecording()
}
private fun startRecording() {
recordingSessionId = UUID.randomUUID().toString()
val startTime = System.currentTimeMillis()
// Open streaming CSV file
val sessionsDir = File(getApplication<Application>().filesDir, "sessions")
.also { it.mkdirs() }
runCatching {
csvWriter = BufferedWriter(FileWriter(File(sessionsDir, "$recordingSessionId.csv")))
csvWriter?.apply {
appendLine("# MicrOBU Session Export")
appendLine("# Generated by MicrOBU v0.2.0 — HAW Hamburg / Project MicrOBU")
appendLine("# Session ID,$recordingSessionId")
appendLine("# Start,${isoFmt.format(Date(startTime))}")
appendLine()
// Columns: type | timestamp_ms | timestamp_iso |
// lat | lon | alt_m | speed_ms | bearing_deg | accuracy_m |
// x | y | z | pressure_hpa
appendLine("type,timestamp_ms,timestamp_iso,lat,lon,alt_m,speed_ms,bearing_deg,accuracy_m,x,y,z,pressure_hpa")
}
}
_state.value = _state.value.copy(
isRecording = true,
recordingStartTime = startTime,
recordingDurationSeconds = 0,
recordingSampleCount = 0,
)
recordingTimerJob = viewModelScope.launch {
while (true) {
delay(1_000)
_state.value = _state.value.copy(
recordingDurationSeconds = _state.value.recordingDurationSeconds + 1
)
}
}
}
private fun stopRecording() {
recordingTimerJob?.cancel()
val s = _state.value
val endTime = System.currentTimeMillis()
// Append footer and close the CSV file
runCatching {
csvWriter?.apply {
appendLine()
appendLine("# End,${isoFmt.format(Date(endTime))}")
appendLine("# Duration (s),${s.recordingDurationSeconds}")
appendLine("# Samples captured,${s.recordingSampleCount}")
flush()
close()
}
}
csvWriter = null
val entity = SessionEntity(
id = recordingSessionId,
startTime = s.recordingStartTime ?: endTime,
endTime = endTime,
durationSeconds = s.recordingDurationSeconds,
sampleCount = s.recordingSampleCount,
)
viewModelScope.launch { dao.insertSession(entity) }
_state.value = s.copy(isRecording = false)
}
// ── CSV row writers ───────────────────────────────────────────────────────
// Columns: type, timestamp_ms, timestamp_iso,
// lat, lon, alt_m, speed_ms, bearing_deg, accuracy_m,
// x, y, z, pressure_hpa (13 total)
private fun writeGnssRow(r: GnssReading) {
val ts = r.timestamp
csvWriter?.appendLine(
"gnss,$ts,${isoFmt.format(Date(ts))},${r.latitude},${r.longitude},${r.altitude},${r.speedMs},${r.bearingDeg},${r.accuracyM},,,,"
)
}
private fun writeImuRow(type: String, r: ImuReading) {
val ts = r.timestamp
csvWriter?.appendLine(
"$type,$ts,${isoFmt.format(Date(ts))},,,,,,,${r.x},${r.y},${r.z},"
)
}
private fun writeBaroRow(hpa: Float) {
val ts = System.currentTimeMillis()
csvWriter?.appendLine(
"baro,$ts,${isoFmt.format(Date(ts))},,,,,,,,,,$hpa"
)
}
// ── Settings ──────────────────────────────────────────────────────────────
fun deleteSession(id: String) {
viewModelScope.launch { dao.deleteById(id) }
// Remove the associated CSV data file
runCatching {
File(File(getApplication<Application>().filesDir, "sessions"), "$id.csv").delete()
}
}
fun setAccelEnabled(v: Boolean) { _state.value = _state.value.copy(accelEnabled = v) }
fun setGyroEnabled(v: Boolean) { _state.value = _state.value.copy(gyroEnabled = v) }
fun setMagnetEnabled(v: Boolean) { _state.value = _state.value.copy(magnetEnabled = v) }
fun setBarometerEnabled(v: Boolean) { _state.value = _state.value.copy(barometerEnabled = v) }
fun setGnssEnabled(v: Boolean) {
_state.value = _state.value.copy(gnssEnabled = v)
if (!v) _state.value = _state.value.copy(gnss = null)
}
fun setDeveloperMode(v: Boolean) { _state.value = _state.value.copy(developerMode = v) }
fun setUseMetric(v: Boolean) { _state.value = _state.value.copy(useMetric = v) }
fun setDarkTheme(v: Boolean) { _state.value = _state.value.copy(darkTheme = v) }
}
@@ -0,0 +1,81 @@
package com.hawhamburg.micr0bu.viewmodel
import android.app.Application
import android.content.Intent
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.hawhamburg.micr0bu.data.TripRepository
import com.hawhamburg.micr0bu.data.db.AppDatabase
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
import com.hawhamburg.micr0bu.service.TripRecordingService
import com.hawhamburg.micr0bu.service.TripServiceBus
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* ViewModel for the Trip Recording feature (Phase A).
*
* Sends [ACTION_START] / [ACTION_STOP] intents to [TripRecordingService] and
* exposes service state via [TripServiceBus]. Also provides trip history and
* per-trip event lists for the History and Review screens.
*/
class TripRecordingViewModel(application: Application) : AndroidViewModel(application) {
private val repository = TripRepository(AppDatabase.getInstance(application))
// ── Service state (live during a recording) ───────────────────────────────
/** Live recording state forwarded from [TripServiceBus]. */
val serviceState: StateFlow<TripServiceBus.State> = TripServiceBus.state
// ── Trip history ─────────────────────────────────────────────────────────
/** All recorded trips, newest first. */
val trips: Flow<List<RecordedTripEntity>> = repository.getAllTrips()
// ── Trip review ───────────────────────────────────────────────────────────
private val _selectedTripEvents = MutableStateFlow<List<DetectedEventEntity>>(emptyList())
val selectedTripEvents: StateFlow<List<DetectedEventEntity>> = _selectedTripEvents.asStateFlow()
/** Load events for [tripId] into [selectedTripEvents]. */
fun loadTripEvents(tripId: Long) {
viewModelScope.launch {
repository.getEventsForTrip(tripId).collect { events ->
_selectedTripEvents.value = events
}
}
}
// ── Recording control ─────────────────────────────────────────────────────
/**
* Starts the foreground recording service if not already running,
* or stops it if a trip is already active.
*/
fun toggleRecording() {
if (serviceState.value.isRecording) stopRecording() else startRecording()
}
fun startRecording() {
val intent = Intent(getApplication(), TripRecordingService::class.java)
.apply { action = TripRecordingService.ACTION_START }
getApplication<Application>().startForegroundService(intent)
}
fun stopRecording() {
val intent = Intent(getApplication(), TripRecordingService::class.java)
.apply { action = TripRecordingService.ACTION_STOP }
getApplication<Application>().startService(intent)
}
// ── Trip management ───────────────────────────────────────────────────────
fun deleteTrip(tripId: Long) {
viewModelScope.launch { repository.deleteTrip(tripId) }
}
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 936 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+216
View File
@@ -0,0 +1,216 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Language names — intentionally NOT translated so they always read in their own language -->
<string name="lang_english">English</string>
<string name="lang_german">Deutsch</string>
<!-- App -->
<string name="app_subtitle">V2X Begleiter — Phase 02</string>
<!-- Navigation -->
<string name="nav_dashboard">Dashboard</string>
<string name="nav_sensors">Sensoren</string>
<string name="nav_record">Messsitzung</string>
<string name="nav_log">Messsitzungen</string>
<string name="nav_v2x">V2X</string>
<string name="nav_settings">Einstellungen</string>
<string name="nav_connection">Verbindung</string>
<!-- Dashboard -->
<string name="dash_gnss">GNSS</string>
<string name="dash_imu">IMU</string>
<string name="dash_obu_connection">OBU-Verbindung</string>
<string name="dash_acquiring_fix">GPS-Signal wird erfasst…</string>
<string name="dash_permission_required">Berechtigung erforderlich</string>
<string name="dash_tap_to_connect">V2X-Tab öffnen zum Verbinden</string>
<string name="dash_connected">Verbunden</string>
<string name="dash_mqtt_connected">MQTT verbunden</string>
<string name="dash_mqtt_connecting">Verbindung zum Broker…</string>
<string name="dash_mqtt_error">Broker nicht erreichbar — V2X-Einstellungen prüfen</string>
<string name="dash_recording">Aufnahme</string>
<string name="dash_samples">Messwerte</string>
<string name="dash_initialising">Wird initialisiert…</string>
<string name="stat_pressure">Luftdruck</string>
<string name="stat_altitude">Höhe</string>
<string name="stat_heading">Richtung</string>
<string name="stat_accuracy">Genauigkeit</string>
<!-- Sensor Screen -->
<string name="sensor_monitor_title">Sensormonitor</string>
<string name="sensor_gnss">Telefon-GNSS</string>
<string name="sensor_accel">Beschleunigungssensor</string>
<string name="sensor_gyro">Gyroskop</string>
<string name="sensor_magnet">Magnetometer</string>
<string name="sensor_baro">Barometer</string>
<string name="sensor_latitude">Breitengrad</string>
<string name="sensor_longitude">Längengrad</string>
<string name="sensor_altitude">Höhe</string>
<string name="sensor_speed">Geschwindigkeit</string>
<string name="sensor_heading">Richtung</string>
<string name="sensor_accuracy">Genauigkeit</string>
<string name="sensor_pressure">Luftdruck</string>
<string name="sensor_est_altitude">Geschätzte Höhe</string>
<string name="sensor_waiting">Warte auf GPS-Signal…</string>
<string name="sensor_initialising">Wird initialisiert…</string>
<string name="sensor_not_present">Sensor nicht verfügbar</string>
<!-- Connection Setup -->
<string name="conn_title">Verbindungseinrichtung</string>
<string name="conn_desc">Verbindung zur micrOBU-Hardware über Bluetooth (primär) oder USB-C (Fallback).</string>
<string name="conn_phase2_desc">USB-C-Tethering ist der primäre Transport in Phase 02. OBU anschließen und Verbinden tippen.</string>
<string name="conn_bluetooth">Bluetooth</string>
<string name="conn_bluetooth_phase3">Bluetooth</string>
<string name="conn_bluetooth_phase3_desc">Bluetooth-Verbindung ist für Phase 03 geplant und noch nicht implementiert.</string>
<string name="conn_scan">Geräte suchen</string>
<string name="conn_scanning">Suche läuft…</string>
<string name="conn_usb_title">USB-C (Primär)</string>
<string name="conn_usb_stub">Über USB-C verbinden (noch nicht implementiert)</string>
<string name="conn_usb_cable_ok">Kabel erkannt</string>
<string name="conn_usb_cable_missing">Kein Kabel</string>
<string name="conn_usb_obu_ip">OBU-IP</string>
<string name="conn_usb_detecting">Wird erkannt…</string>
<string name="conn_usb_mode">IP-Quelle</string>
<string name="conn_usb_auto">Automatisch erkannt</string>
<string name="conn_usb_manual">Manuell</string>
<string name="conn_usb_no_cable_hint">⚠ USB-C-Kabel anschließen, oder Auto-Erkennung in Einstellungen → Verbindung deaktivieren.</string>
<string name="conn_connect">Verbinden</string>
<string name="conn_connect_mqtt">Mit OBU verbinden</string>
<string name="conn_disconnect">Trennen</string>
<string name="conn_selected">Ausgewählt</string>
<string name="conn_connected_stub">Verbunden (Stub)</string>
<string name="conn_stub_warning">⚠ Bluetooth noch nicht implementiert. Dieser Bildschirm zeigt das Ziel-UI-Layout.</string>
<!-- Recording -->
<string name="rec_title">Messsitzung</string>
<string name="rec_ready">Bereit zur Aufnahme</string>
<string name="rec_samples_captured">%1$d Messwerte erfasst</string>
<string name="rec_active_streams">Aktive Datenströme</string>
<string name="rec_button_stop">STOPP</string>
<string name="rec_button_rec">REC</string>
<string name="stream_gnss">Telefon-GNSS</string>
<string name="stream_accel">Beschleunigungssensor</string>
<string name="stream_gyro">Gyroskop</string>
<string name="stream_magnet">Magnetometer</string>
<string name="stream_baro">Barometer</string>
<string name="stream_obu">OBU / MQTT</string>
<!-- Session Log -->
<string name="log_title">Messsitzungen</string>
<string name="log_stored_one">%1$d Sitzung auf dem Gerät gespeichert</string>
<string name="log_stored_other">%1$d Sitzungen auf dem Gerät gespeichert</string>
<string name="log_session_number">Sitzung #%1$d</string>
<string name="log_share_cd">CSV teilen</string>
<string name="log_save_cd">Auf Gerät speichern</string>
<string name="log_delete_cd">Sitzung löschen</string>
<string name="log_empty_title">Noch keine Sitzungen</string>
<string name="log_empty_desc">Gehe zu Aufnahme und starte eine Sitzung</string>
<string name="log_delete_dialog_title">Sitzung löschen?</string>
<string name="log_delete_dialog_text">Die am %1$s aufgezeichnete Sitzung wird dauerhaft vom Gerät entfernt.</string>
<string name="log_delete_confirm">Löschen</string>
<string name="log_cancel">Abbrechen</string>
<string name="log_export_chooser">Sitzung exportieren</string>
<!-- GNSS bottom sheet & map -->
<string name="gnss_sheet_title">GNSS-Standort</string>
<string name="gnss_open_maps">In Maps-App öffnen</string>
<string name="gnss_open_maps_sub">Öffnet Ihre bevorzugte Karten-App</string>
<string name="gnss_view_inapp">In App anzeigen</string>
<string name="gnss_view_inapp_sub">Aktuellen Standort auf einer In-App-Karte anzeigen</string>
<string name="gnss_no_fix">Noch kein GPS-Signal — gehen Sie ins Freie</string>
<string name="map_title">Standortkarte</string>
<string name="map_location_label">Aktueller Standort</string>
<!-- Settings -->
<string name="settings_title">Einstellungen</string>
<string name="settings_language">Sprache</string>
<string name="settings_appearance">Darstellung</string>
<string name="settings_dark_theme">Dunkles Design</string>
<string name="settings_sensors">Datenströme</string>
<string name="settings_gnss">Telefon-GNSS</string>
<string name="settings_accel">Beschleunigungssensor</string>
<string name="settings_gyro">Gyroskop</string>
<string name="settings_magnet">Magnetometer</string>
<string name="settings_baro">Barometer</string>
<string name="settings_units">Einheiten</string>
<string name="settings_metric">Metrisch (m, km/h, hPa)</string>
<string name="settings_mqtt_section">MQTT-Broker</string>
<string name="settings_broker_ip">Broker-IP</string>
<string name="settings_port">Port</string>
<string name="settings_username">Benutzername</string>
<string name="settings_password">Passwort</string>
<string name="settings_optional">optional</string>
<string name="settings_show">Anzeigen</string>
<string name="settings_hide">Ausblenden</string>
<string name="settings_developer">Entwickler</string>
<string name="settings_dev_mode">Entwicklermodus</string>
<string name="settings_wifi">WLAN-OBU-Verbindung</string>
<string name="settings_wifi_val">Nur Entwicklermodus — noch nicht implementiert</string>
<string name="settings_about">Über</string>
<string name="settings_app_version">App-Version</string>
<string name="settings_app_version_val">0.4.0 (Phase 02 — USB-C + DENM TX)</string>
<string name="settings_connection">Verbindung</string>
<string name="settings_usb_auto_detect">OBU per USB-C automatisch erkennen</string>
<string name="settings_usb_manual_ip">OBU-IP (manuell)</string>
<string name="settings_usb_transport">Aktiver Transport</string>
<string name="settings_transport_usbc">USB-C</string>
<string name="settings_transport_wifi">WLAN</string>
<string name="settings_save_broker">Broker-Einstellungen speichern</string>
<!-- MQTT Topic Viewer -->
<string name="mqtt_viewer_title">V2X-Monitor</string>
<string name="mqtt_connect">Verbinden</string>
<string name="mqtt_disconnect">Trennen</string>
<string name="mqtt_auto_scroll">Automatisch scrollen</string>
<string name="mqtt_no_topics">Noch keine Nachrichten</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>
<!-- DENM TX -->
<string name="mqtt_last_tx">Zuletzt gesendete Nutzlast:</string>
<string name="mqtt_denm_tx_title">DENM-Übertragung</string>
<string name="mqtt_send_denm">Test-DENM senden</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_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_hint">Sendet uca-denmctrl (retained) an v2x-uca/input/denmtrg</string>
<string name="mqtt_denm_show">Letztes TX anzeigen</string>
<string name="mqtt_denm_hide">Ausblenden</string>
<!-- Dashboard transport -->
<string name="dash_transport_usbc">USB-C</string>
<string name="dash_transport_wifi">WLAN</string>
<string name="dash_transport_bt">BT</string>
<!-- OBU stationType warning -->
<string name="dash_station_type_warning">⚠ OBU-stationType ≠ 2 (Radfahrer) — VRU-Erkennung an ausgerüsteten Kreuzungen ggf. beeinträchtigt</string>
<string name="settings_platform">Plattform</string>
<string name="settings_platform_val">Android / Kotlin / Jetpack Compose</string>
<string name="settings_project">Projekt</string>
<string name="settings_project_val">MicrOBU — HAW Hamburg</string>
<!-- Phase A: Trips (bottom nav) -->
<string name="nav_trips">Fahrten</string>
<!-- Phase A: Recording screen event counters -->
<string name="rec_events_detected">Erkannte Ereignisse</string>
<string name="rec_event_braking">Bremsen</string>
<string name="rec_event_turning">Abbiegen</string>
<string name="rec_event_stopping">Anhalten</string>
<string name="rec_stream_event_detection">Ereigniserkennung</string>
<string name="rec_open_session_log">CSV-Sitzungsprotokoll</string>
<!-- Phase A: Trip History screen -->
<string name="trip_history_title">Fahrtenverlauf</string>
<string name="trip_history_count_one">%1$d Fahrt auf dem Gerät gespeichert</string>
<string name="trip_history_count_other">%1$d Fahrten auf dem Gerät gespeichert</string>
<string name="trip_number">Fahrt #%1$d</string>
<string name="trip_delete_title">Fahrt löschen?</string>
<string name="trip_delete_text">Die am %1$s aufgezeichnete Fahrt wird dauerhaft vom Gerät entfernt.</string>
<string name="trip_empty_title">Noch keine Fahrten</string>
<string name="trip_empty_desc">Gehe zu Aufnahme und starte eine Sitzung</string>
<!-- Phase A: Trip Review screen -->
<string name="trip_review_title">Fahrtanalyse</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#F8F8F8</color>
</resources>
+217
View File
@@ -0,0 +1,217 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- App -->
<string name="app_name">MicrOBU</string>
<string name="app_subtitle">V2X Companion — Phase 02</string>
<!-- Language names (always shown in their own language) -->
<string name="lang_english">English</string>
<string name="lang_german">Deutsch</string>
<!-- Navigation -->
<string name="nav_dashboard">Dashboard</string>
<string name="nav_sensors">Sensors</string>
<string name="nav_record">Record</string>
<string name="nav_log">Log</string>
<string name="nav_v2x">V2X</string>
<string name="nav_settings">Settings</string>
<string name="nav_connection">Connection</string>
<!-- Dashboard -->
<string name="dash_gnss">GNSS</string>
<string name="dash_imu">IMU</string>
<string name="dash_obu_connection">OBU Connection</string>
<string name="dash_acquiring_fix">Acquiring fix…</string>
<string name="dash_permission_required">Permission required</string>
<string name="dash_tap_to_connect">Tap V2X to connect</string>
<string name="dash_connected">Connected</string>
<string name="dash_mqtt_connected">MQTT connected</string>
<string name="dash_mqtt_connecting">Connecting to broker…</string>
<string name="dash_mqtt_error">Broker unreachable — check V2X settings</string>
<string name="dash_recording">Recording</string>
<string name="dash_samples">samples</string>
<string name="dash_initialising">Initialising…</string>
<string name="stat_pressure">Pressure</string>
<string name="stat_altitude">Altitude</string>
<string name="stat_heading">Heading</string>
<string name="stat_accuracy">Accuracy</string>
<!-- Sensor Screen -->
<string name="sensor_monitor_title">Sensor Monitor</string>
<string name="sensor_gnss">Phone GNSS</string>
<string name="sensor_accel">Accelerometer</string>
<string name="sensor_gyro">Gyroscope</string>
<string name="sensor_magnet">Magnetometer</string>
<string name="sensor_baro">Barometer</string>
<string name="sensor_latitude">Latitude</string>
<string name="sensor_longitude">Longitude</string>
<string name="sensor_altitude">Altitude</string>
<string name="sensor_speed">Speed</string>
<string name="sensor_heading">Heading</string>
<string name="sensor_accuracy">Accuracy</string>
<string name="sensor_pressure">Pressure</string>
<string name="sensor_est_altitude">Est. altitude</string>
<string name="sensor_waiting">Waiting for location fix…</string>
<string name="sensor_initialising">Initialising…</string>
<string name="sensor_not_present">Sensor not present on this device</string>
<!-- Connection Setup -->
<string name="conn_title">Connection Setup</string>
<string name="conn_desc">Connect to the micrOBU hardware via Bluetooth (primary) or USB-C (fallback).</string>
<string name="conn_phase2_desc">USB-C tethering is the primary transport in Phase 02. Plug the OBU in and tap Connect.</string>
<string name="conn_bluetooth">Bluetooth</string>
<string name="conn_bluetooth_phase3">Bluetooth</string>
<string name="conn_bluetooth_phase3_desc">Bluetooth connection is planned for Phase 03 and is not yet implemented.</string>
<string name="conn_scan">Scan for Devices</string>
<string name="conn_scanning">Scanning…</string>
<string name="conn_usb_title">USB-C (Primary)</string>
<string name="conn_usb_stub">Connect via USB-C (not yet implemented)</string>
<string name="conn_usb_cable_ok">Cable detected</string>
<string name="conn_usb_cable_missing">No cable</string>
<string name="conn_usb_obu_ip">OBU IP</string>
<string name="conn_usb_detecting">Detecting…</string>
<string name="conn_usb_mode">IP source</string>
<string name="conn_usb_auto">Auto-detected</string>
<string name="conn_usb_manual">Manual override</string>
<string name="conn_usb_no_cable_hint">⚠ Connect the USB-C cable to detect the OBU, or disable auto-detect in Settings → Connection to use a manual IP.</string>
<string name="conn_connect">Connect</string>
<string name="conn_connect_mqtt">Connect to OBU</string>
<string name="conn_disconnect">Disconnect</string>
<string name="conn_selected">Selected</string>
<string name="conn_connected_stub">Connected (stub)</string>
<string name="conn_stub_warning">⚠ Bluetooth stack not yet implemented. This screen shows the target UI layout.</string>
<!-- Recording -->
<string name="rec_title">Recording Session</string>
<string name="rec_ready">Ready to record</string>
<string name="rec_samples_captured">%1$d samples captured</string>
<string name="rec_active_streams">Active Streams</string>
<string name="rec_button_stop">STOP</string>
<string name="rec_button_rec">REC</string>
<string name="stream_gnss">Phone GNSS</string>
<string name="stream_accel">Accelerometer</string>
<string name="stream_gyro">Gyroscope</string>
<string name="stream_magnet">Magnetometer</string>
<string name="stream_baro">Barometer</string>
<string name="stream_obu">OBU / MQTT</string>
<!-- Session Log -->
<string name="log_title">Session Log</string>
<string name="log_stored_one">%1$d session stored on device</string>
<string name="log_stored_other">%1$d sessions stored on device</string>
<string name="log_session_number">Session #%1$d</string>
<string name="log_share_cd">Share CSV</string>
<string name="log_save_cd">Save to device</string>
<string name="log_delete_cd">Delete session</string>
<string name="log_empty_title">No sessions yet</string>
<string name="log_empty_desc">Go to Record and start a session</string>
<string name="log_delete_dialog_title">Delete session?</string>
<string name="log_delete_dialog_text">This will permanently remove the session recorded on %1$s from the device.</string>
<string name="log_delete_confirm">Delete</string>
<string name="log_cancel">Cancel</string>
<string name="log_export_chooser">Export session</string>
<!-- GNSS bottom sheet & map -->
<string name="gnss_sheet_title">GNSS Location</string>
<string name="gnss_open_maps">Open in Maps App</string>
<string name="gnss_open_maps_sub">Opens in your preferred maps application</string>
<string name="gnss_view_inapp">View in App</string>
<string name="gnss_view_inapp_sub">Show current location on an in-app map</string>
<string name="gnss_no_fix">No GPS fix yet — move to an open area</string>
<string name="map_title">Location Map</string>
<string name="map_location_label">Current Location</string>
<!-- Settings -->
<string name="settings_title">Settings</string>
<string name="settings_language">Language</string>
<string name="settings_appearance">Appearance</string>
<string name="settings_dark_theme">Dark theme</string>
<string name="settings_sensors">Sensor Streams</string>
<string name="settings_gnss">Phone GNSS</string>
<string name="settings_accel">Accelerometer</string>
<string name="settings_gyro">Gyroscope</string>
<string name="settings_magnet">Magnetometer</string>
<string name="settings_baro">Barometer</string>
<string name="settings_units">Units</string>
<string name="settings_metric">Metric (m, km/h, hPa)</string>
<string name="settings_mqtt_section">MQTT Broker</string>
<string name="settings_broker_ip">Broker IP</string>
<string name="settings_port">Port</string>
<string name="settings_username">Username</string>
<string name="settings_password">Password</string>
<string name="settings_optional">optional</string>
<string name="settings_show">Show</string>
<string name="settings_hide">Hide</string>
<string name="settings_developer">Developer</string>
<string name="settings_dev_mode">Developer mode</string>
<string name="settings_wifi">Wi-Fi OBU connection</string>
<string name="settings_wifi_val">Dev mode only — not implemented</string>
<string name="settings_about">About</string>
<string name="settings_app_version">App version</string>
<string name="settings_app_version_val">0.4.0 (Phase 02 — USB-C + DENM TX)</string>
<string name="settings_connection">Connection</string>
<string name="settings_usb_auto_detect">Auto-detect OBU via USB-C</string>
<string name="settings_usb_manual_ip">Manual OBU IP</string>
<string name="settings_usb_transport">Active transport</string>
<string name="settings_transport_usbc">USB-C</string>
<string name="settings_transport_wifi">Wi-Fi</string>
<string name="settings_save_broker">Save Broker Settings</string>
<!-- MQTT Topic Viewer -->
<string name="mqtt_viewer_title">V2X Monitor</string>
<string name="mqtt_connect">Connect</string>
<string name="mqtt_disconnect">Disconnect</string>
<string name="mqtt_auto_scroll">Auto-scroll to latest</string>
<string name="mqtt_no_topics">No messages yet</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>
<!-- DENM TX -->
<string name="mqtt_last_tx">Last transmitted payload:</string>
<string name="mqtt_denm_tx_title">DENM Transmission</string>
<string name="mqtt_send_denm">Send Test 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_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_hint">Publishes uca-denmctrl (retained) to v2x-uca/input/denmtrg</string>
<string name="mqtt_denm_show">Show last TX</string>
<string name="mqtt_denm_hide">Hide</string>
<!-- Dashboard transport -->
<string name="dash_transport_usbc">USB-C</string>
<string name="dash_transport_wifi">Wi-Fi</string>
<string name="dash_transport_bt">BT</string>
<!-- OBU stationType warning -->
<string name="dash_station_type_warning">⚠ OBU stationType ≠ 2 (cyclist) — VRU detection may be impaired at equipped intersections</string>
<string name="settings_platform">Platform</string>
<string name="settings_platform_val">Android / Kotlin / Jetpack Compose</string>
<string name="settings_project">Project</string>
<string name="settings_project_val">MicrOBU — HAW Hamburg</string>
<!-- Phase A: Trip Recording (bottom nav) -->
<string name="nav_trips">Trips</string>
<!-- Phase A: Recording screen event counters -->
<string name="rec_events_detected">Detected Events</string>
<string name="rec_event_braking">Braking</string>
<string name="rec_event_turning">Turning</string>
<string name="rec_event_stopping">Stopping</string>
<string name="rec_stream_event_detection">Event Detection</string>
<string name="rec_open_session_log">CSV Session Log</string>
<!-- Phase A: Trip History screen -->
<string name="trip_history_title">Trip History</string>
<string name="trip_history_count_one">%1$d trip stored on device</string>
<string name="trip_history_count_other">%1$d trips stored on device</string>
<string name="trip_number">Trip #%1$d</string>
<string name="trip_delete_title">Delete trip?</string>
<string name="trip_delete_text">This will permanently remove the trip recorded on %1$s.</string>
<string name="trip_empty_title">No trips yet</string>
<string name="trip_empty_desc">Go to Record and start a session</string>
<!-- Phase A: Trip Review screen -->
<string name="trip_review_title">Trip Review</string>
</resources>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.MicrOBU" parent="Theme.AppCompat.Light.NoActionBar" />
<style name="Theme.MicrOBU.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">#FFFFFF</item>
<item name="windowSplashScreenAnimatedIcon">@mipmap/ic_launcher_foreground</item>
<item name="postSplashScreenTheme">@style/Theme.MicrOBU</item>
</style>
</resources>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- Covers context.cacheDir — used for CSV exports -->
<cache-path name="shared_csv" path="." />
</paths>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
<locale android:name="de" />
</locale-config>