diff --git a/V2X_MicrOBU_Android_App_Requirements_v9.docx b/V2X_MicrOBU_Android_App_Requirements_v9.docx new file mode 100644 index 0000000..1babb2c Binary files /dev/null and b/V2X_MicrOBU_Android_App_Requirements_v9.docx differ diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..9a3ad3f --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,82 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) +} + +android { + namespace = "com.hawhamburg.micr0bu" + compileSdk = 36 + + defaultConfig { + applicationId = "com.hawhamburg.micr0bu" + minSdk = 29 + targetSdk = 36 + versionCode = 1 + versionName = "0.1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.activity.compose) + + val composeBom = platform(libs.compose.bom) + implementation(composeBom) + implementation(libs.compose.ui) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.material3) + implementation(libs.compose.material.icons.extended) + + implementation(libs.navigation.compose) + implementation(libs.play.services.location) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.coroutines.play.services) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.hilt.navigation.compose) + implementation(libs.datastore.preferences) + implementation(libs.paho.mqtt) + + implementation(libs.room.runtime) + implementation(libs.room.ktx) + ksp(libs.room.compiler) + implementation(libs.appcompat) + implementation(libs.osmdroid) + implementation(libs.androidx.core.splashscreen) + + debugImplementation(libs.compose.ui.tooling) + + // ── Unit tests (JVM, no emulator required) ──────────────────────────────── + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..fb164d6 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1 @@ +# Add project specific ProGuard rules here. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..19837d2 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..c5e359e Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt b/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt new file mode 100644 index 0000000..5508c2b --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt @@ -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) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/MicrOBUApplication.kt b/app/src/main/java/com/hawhamburg/micr0bu/MicrOBUApplication.kt new file mode 100644 index 0000000..b1b1d8c --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/MicrOBUApplication.kt @@ -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" + } + ) + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/CsvExporter.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/CsvExporter.kt new file mode 100644 index 0000000..dab3130 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/CsvExporter.kt @@ -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))}") +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/SensorRepository.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/SensorRepository.kt new file mode 100644 index 0000000..9aa67a6 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/SensorRepository.kt @@ -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 = sensorFlow(Sensor.TYPE_ACCELEROMETER) + + fun gyroscopeFlow(): Flow = sensorFlow(Sensor.TYPE_GYROSCOPE) + + fun magnetometerFlow(): Flow = sensorFlow(Sensor.TYPE_MAGNETIC_FIELD) + + fun barometerFlow(): Flow = 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 = 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 = 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 +) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/TripRepository.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/TripRepository.kt new file mode 100644 index 0000000..c4106bf --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/TripRepository.kt @@ -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> = 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> = + dao.getEventsForTrip(tripId) +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/db/AppDatabase.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/db/AppDatabase.kt new file mode 100644 index 0000000..69c9608 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/db/AppDatabase.kt @@ -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`)" + ) + } + } + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/db/DetectedEventEntity.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/db/DetectedEventEntity.kt new file mode 100644 index 0000000..49bbfcc --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/db/DetectedEventEntity.kt @@ -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, +) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/db/RecordedTripEntity.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/db/RecordedTripEntity.kt new file mode 100644 index 0000000..125cc57 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/db/RecordedTripEntity.kt @@ -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, +) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/db/SessionDao.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/db/SessionDao.kt new file mode 100644 index 0000000..4d8b923 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/db/SessionDao.kt @@ -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> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertSession(session: SessionEntity) + + @Query("DELETE FROM sessions WHERE id = :id") + suspend fun deleteById(id: String) +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/db/SessionEntity.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/db/SessionEntity.kt new file mode 100644 index 0000000..8ab7df5 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/db/SessionEntity.kt @@ -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, +) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/db/TripDao.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/db/TripDao.kt new file mode 100644 index 0000000..a6f7365 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/db/TripDao.kt @@ -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> + + @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> + + @Query("SELECT COUNT(*) FROM detected_events WHERE tripId = :tripId") + suspend fun getEventCountForTrip(tripId: Long): Int +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/CamBuilder.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/CamBuilder.kt new file mode 100644 index 0000000..f06f98c --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/CamBuilder.kt @@ -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() + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttConnectionState.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttConnectionState.kt new file mode 100644 index 0000000..4ea0713 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttConnectionState.kt @@ -0,0 +1,8 @@ +package com.hawhamburg.micr0bu.data.mqtt + +enum class MqttConnectionState { + DISCONNECTED, + CONNECTING, + CONNECTED, + ERROR, +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttMessage.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttMessage.kt new file mode 100644 index 0000000..39af617 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttMessage.kt @@ -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, +) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttPreferences.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttPreferences.kt new file mode 100644 index 0000000..bfcb769 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttPreferences.kt @@ -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 = 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 + } + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttRepository.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttRepository.kt new file mode 100644 index 0000000..0a63766 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttRepository.kt @@ -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 = _connectionState.asStateFlow() + + // extraBufferCapacity absorbs bursts (e.g. spat at 2 Hz, cam live) without blocking Paho's thread + private val _messages = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 512, + ) + val messages: SharedFlow = _messages.asSharedFlow() + + /** Active transport derived from persisted prefs. */ + val activeTransport: StateFlow = 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() + + 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) + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/transport/TransportType.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/TransportType.kt new file mode 100644 index 0000000..c3a76e2 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/TransportType.kt @@ -0,0 +1,3 @@ +package com.hawhamburg.micr0bu.data.transport + +enum class TransportType { USB_C, WIFI, BLUETOOTH } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/transport/UsbNetworkDetector.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/UsbNetworkDetector.kt new file mode 100644 index 0000000..15e1f02 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/UsbNetworkDetector.kt @@ -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(null) + /** Non-null when a 192.168.42.x USB tethering network is active. */ + val usbNetwork: StateFlow = _usbNetwork.asStateFlow() + + private val _detectedGatewayIp = MutableStateFlow(null) + /** + * IP of the default gateway on the USB interface — almost always the OBU's IP. + * Falls back to .1 if no default route is reported. + */ + val detectedGatewayIp: StateFlow = _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" + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/DetectedEvent.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/DetectedEvent.kt new file mode 100644 index 0000000..0032524 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/DetectedEvent.kt @@ -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, +) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/DetectionConfig.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/DetectionConfig.kt new file mode 100644 index 0000000..50c8fcd --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/DetectionConfig.kt @@ -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, +) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/EventDetector.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/EventDetector.kt new file mode 100644 index 0000000..871ffe0 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/EventDetector.kt @@ -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(extraBufferCapacity = 64) + val events: SharedFlow = _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 + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/RunningStats.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/RunningStats.kt new file mode 100644 index 0000000..c6231ab --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/RunningStats.kt @@ -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 + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/service/TripRecordingService.kt b/app/src/main/java/com/hawhamburg/micr0bu/service/TripRecordingService.kt new file mode 100644 index 0000000..d1025a6 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/service/TripRecordingService.kt @@ -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) + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/service/TripServiceBus.kt b/app/src/main/java/com/hawhamburg/micr0bu/service/TripServiceBus.kt new file mode 100644 index 0000000..0667e04 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/service/TripServiceBus.kt @@ -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.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() + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/components/StatusTopBar.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/components/StatusTopBar.kt new file mode 100644 index 0000000..79c40b3 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/components/StatusTopBar.kt @@ -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)), + ) +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt new file mode 100644 index 0000000..f2b7201 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt @@ -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 + } + }, + ) +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/ConnectionSetupScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/ConnectionSetupScreen.kt new file mode 100644 index 0000000..7d1858f --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/ConnectionSetupScreen.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/DashboardScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/DashboardScreen.kt new file mode 100644 index 0000000..57468ba --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/DashboardScreen.kt @@ -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) + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MapScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MapScreen.kt new file mode 100644 index 0000000..0f9a251 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MapScreen.kt @@ -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(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) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt new file mode 100644 index 0000000..13559b5 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt @@ -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, + topicMessages: Map>, + 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, + 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 + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/RecordingScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/RecordingScreen.kt new file mode 100644 index 0000000..56463e1 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/RecordingScreen.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SensorScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SensorScreen.kt new file mode 100644 index 0000000..b73305e --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SensorScreen.kt @@ -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) +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SessionLogScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SessionLogScreen.kt new file mode 100644 index 0000000..18403a1 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SessionLogScreen.kt @@ -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(null) } + var pendingSave by remember { mutableStateOf(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) + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SettingsScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..84fc73f --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SettingsScreen.kt @@ -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) + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/TripHistoryScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/TripHistoryScreen.kt new file mode 100644 index 0000000..d54a4a1 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/TripHistoryScreen.kt @@ -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(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) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/TripReviewScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/TripReviewScreen.kt new file mode 100644 index 0000000..5759ffe --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/TripReviewScreen.kt @@ -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(null) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + + val mapViewRef = remember { mutableStateOf(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 = + 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) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/theme/Theme.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/theme/Theme.kt new file mode 100644 index 0000000..4b77d3b --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/theme/Theme.kt @@ -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 + ) +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt new file mode 100644 index 0000000..8fc1feb --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt @@ -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 = repo.connectionState + + private val _topicMessages = MutableStateFlow>>(emptyMap()) + val topicMessages: StateFlow>> = _topicMessages.asStateFlow() + + private val _selectedTopic = MutableStateFlow(null) + val selectedTopic: StateFlow = _selectedTopic.asStateFlow() + + private val _autoScroll = MutableStateFlow(true) + val autoScroll: StateFlow = _autoScroll.asStateFlow() + + // ── Transport & USB ─────────────────────────────────────────────────────── + + val activeTransport: StateFlow = repo.activeTransport + + /** True when a 192.168.42.x USB-C tethering network is detected. */ + val usbConnected: StateFlow = usbDetector.usbNetwork + .map { it != null } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + /** Auto-detected OBU gateway IP on the USB interface. */ + val detectedObuIp: StateFlow = usbDetector.detectedGatewayIp + + // ── Prefs ───────────────────────────────────────────────────────────────── + + val mqttPrefs: StateFlow = prefs.prefsFlow.stateIn( + viewModelScope, + SharingStarted.Eagerly, + MqttPrefs(), + ) + + // ── OBU identity (parsed from v2x/rx/obu_gnss own_info) ────────────────── + + private val _obuStationType = MutableStateFlow(null) + /** + * stationType from the OBU's own_info (v2x/rx/obu_gnss). Should be 2 (cyclist/VRU) per + * ETSI EN 302 637-2 Table 1. Null until the first obu_gnss message arrives. + */ + val obuStationType: StateFlow = _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 = _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 = _denmActive.asStateFlow() + + private val _lastDenmPayload = MutableStateFlow(null) + /** JSON string of the most recently transmitted DENM control message. */ + val lastDenmPayload: StateFlow = _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() + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/SensorViewModel.kt b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/SensorViewModel.kt new file mode 100644 index 0000000..dd37f49 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/SensorViewModel.kt @@ -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 = 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 = _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().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().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) } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/TripRecordingViewModel.kt b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/TripRecordingViewModel.kt new file mode 100644 index 0000000..0152888 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/TripRecordingViewModel.kt @@ -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 + + // ── Trip history ───────────────────────────────────────────────────────── + + /** All recorded trips, newest first. */ + val trips: Flow> = repository.getAllTrips() + + // ── Trip review ─────────────────────────────────────────────────────────── + + private val _selectedTripEvents = MutableStateFlow>(emptyList()) + val selectedTripEvents: StateFlow> = _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().startForegroundService(intent) + } + + fun stopRecording() { + val intent = Intent(getApplication(), TripRecordingService::class.java) + .apply { action = TripRecordingService.ACTION_STOP } + getApplication().startService(intent) + } + + // ── Trip management ─────────────────────────────────────────────────────── + + fun deleteTrip(tripId: Long) { + viewModelScope.launch { repository.deleteTrip(tripId) } + } +} diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..65291b9 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..65291b9 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..7046dbe Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..9be1b09 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..6c1c516 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..53f5191 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..d63f252 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..8d87500 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..01bcbc7 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..901a758 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..2e3795c Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..24a86b6 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..6610a1b Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..fb5ce0a Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..11ad8c9 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..1b65906 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..d27ca70 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..17d616c --- /dev/null +++ b/app/src/main/res/values-de/strings.xml @@ -0,0 +1,216 @@ + + + + English + Deutsch + + + V2X Begleiter — Phase 02 + + + Dashboard + Sensoren + Messsitzung + Messsitzungen + V2X + Einstellungen + Verbindung + + + GNSS + IMU + OBU-Verbindung + GPS-Signal wird erfasst… + Berechtigung erforderlich + V2X-Tab öffnen zum Verbinden + Verbunden + MQTT verbunden + Verbindung zum Broker… + Broker nicht erreichbar — V2X-Einstellungen prüfen + Aufnahme + Messwerte + Wird initialisiert… + Luftdruck + Höhe + Richtung + Genauigkeit + + + Sensormonitor + Telefon-GNSS + Beschleunigungssensor + Gyroskop + Magnetometer + Barometer + Breitengrad + Längengrad + Höhe + Geschwindigkeit + Richtung + Genauigkeit + Luftdruck + Geschätzte Höhe + Warte auf GPS-Signal… + Wird initialisiert… + Sensor nicht verfügbar + + + Verbindungseinrichtung + Verbindung zur micrOBU-Hardware über Bluetooth (primär) oder USB-C (Fallback). + USB-C-Tethering ist der primäre Transport in Phase 02. OBU anschließen und Verbinden tippen. + Bluetooth + Bluetooth + Bluetooth-Verbindung ist für Phase 03 geplant und noch nicht implementiert. + Geräte suchen + Suche läuft… + USB-C (Primär) + Über USB-C verbinden (noch nicht implementiert) + Kabel erkannt + Kein Kabel + OBU-IP + Wird erkannt… + IP-Quelle + Automatisch erkannt + Manuell + ⚠ USB-C-Kabel anschließen, oder Auto-Erkennung in Einstellungen → Verbindung deaktivieren. + Verbinden + Mit OBU verbinden + Trennen + Ausgewählt + Verbunden (Stub) + ⚠ Bluetooth noch nicht implementiert. Dieser Bildschirm zeigt das Ziel-UI-Layout. + + + Messsitzung + Bereit zur Aufnahme + %1$d Messwerte erfasst + Aktive Datenströme + STOPP + REC + Telefon-GNSS + Beschleunigungssensor + Gyroskop + Magnetometer + Barometer + OBU / MQTT + + + Messsitzungen + %1$d Sitzung auf dem Gerät gespeichert + %1$d Sitzungen auf dem Gerät gespeichert + Sitzung #%1$d + CSV teilen + Auf Gerät speichern + Sitzung löschen + Noch keine Sitzungen + Gehe zu Aufnahme und starte eine Sitzung + Sitzung löschen? + Die am %1$s aufgezeichnete Sitzung wird dauerhaft vom Gerät entfernt. + Löschen + Abbrechen + Sitzung exportieren + + + GNSS-Standort + In Maps-App öffnen + Öffnet Ihre bevorzugte Karten-App + In App anzeigen + Aktuellen Standort auf einer In-App-Karte anzeigen + Noch kein GPS-Signal — gehen Sie ins Freie + Standortkarte + Aktueller Standort + + + Einstellungen + Sprache + Darstellung + Dunkles Design + Datenströme + Telefon-GNSS + Beschleunigungssensor + Gyroskop + Magnetometer + Barometer + Einheiten + Metrisch (m, km/h, hPa) + MQTT-Broker + Broker-IP + Port + Benutzername + Passwort + optional + Anzeigen + Ausblenden + Entwickler + Entwicklermodus + WLAN-OBU-Verbindung + Nur Entwicklermodus — noch nicht implementiert + Über + App-Version + 0.4.0 (Phase 02 — USB-C + DENM TX) + Verbindung + OBU per USB-C automatisch erkennen + OBU-IP (manuell) + Aktiver Transport + USB-C + WLAN + Broker-Einstellungen speichern + + + V2X-Monitor + Verbinden + Trennen + Automatisch scrollen + Noch keine Nachrichten + Mit der OBU verbinden und auf V2X-Verkehr warten + Noch keine Nachrichten zu diesem Thema + + + Zuletzt gesendete Nutzlast: + DENM-Übertragung + Test-DENM senden + DENM stoppen + Liegengebliebenes Fahrzeug (causeCode 94) + Mit OBU verbinden, um DENM-Auslösung zu aktivieren + DENM aktiv — OBU sendet über ITS-G5 + Sendet uca-denmctrl (retained) an v2x-uca/input/denmtrg + Letztes TX anzeigen + Ausblenden + + + USB-C + WLAN + BT + + + ⚠ OBU-stationType ≠ 2 (Radfahrer) — VRU-Erkennung an ausgerüsteten Kreuzungen ggf. beeinträchtigt + + Plattform + Android / Kotlin / Jetpack Compose + Projekt + MicrOBU — HAW Hamburg + + + Fahrten + + + Erkannte Ereignisse + Bremsen + Abbiegen + Anhalten + Ereigniserkennung + CSV-Sitzungsprotokoll + + + Fahrtenverlauf + %1$d Fahrt auf dem Gerät gespeichert + %1$d Fahrten auf dem Gerät gespeichert + Fahrt #%1$d + Fahrt löschen? + Die am %1$s aufgezeichnete Fahrt wird dauerhaft vom Gerät entfernt. + Noch keine Fahrten + Gehe zu Aufnahme und starte eine Sitzung + + + Fahrtanalyse + diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..a766863 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #F8F8F8 + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..d85e526 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,217 @@ + + + + MicrOBU + V2X Companion — Phase 02 + + + English + Deutsch + + + Dashboard + Sensors + Record + Log + V2X + Settings + Connection + + + GNSS + IMU + OBU Connection + Acquiring fix… + Permission required + Tap V2X to connect + Connected + MQTT connected + Connecting to broker… + Broker unreachable — check V2X settings + Recording + samples + Initialising… + Pressure + Altitude + Heading + Accuracy + + + Sensor Monitor + Phone GNSS + Accelerometer + Gyroscope + Magnetometer + Barometer + Latitude + Longitude + Altitude + Speed + Heading + Accuracy + Pressure + Est. altitude + Waiting for location fix… + Initialising… + Sensor not present on this device + + + Connection Setup + Connect to the micrOBU hardware via Bluetooth (primary) or USB-C (fallback). + USB-C tethering is the primary transport in Phase 02. Plug the OBU in and tap Connect. + Bluetooth + Bluetooth + Bluetooth connection is planned for Phase 03 and is not yet implemented. + Scan for Devices + Scanning… + USB-C (Primary) + Connect via USB-C (not yet implemented) + Cable detected + No cable + OBU IP + Detecting… + IP source + Auto-detected + Manual override + ⚠ Connect the USB-C cable to detect the OBU, or disable auto-detect in Settings → Connection to use a manual IP. + Connect + Connect to OBU + Disconnect + Selected + Connected (stub) + ⚠ Bluetooth stack not yet implemented. This screen shows the target UI layout. + + + Recording Session + Ready to record + %1$d samples captured + Active Streams + STOP + REC + Phone GNSS + Accelerometer + Gyroscope + Magnetometer + Barometer + OBU / MQTT + + + Session Log + %1$d session stored on device + %1$d sessions stored on device + Session #%1$d + Share CSV + Save to device + Delete session + No sessions yet + Go to Record and start a session + Delete session? + This will permanently remove the session recorded on %1$s from the device. + Delete + Cancel + Export session + + + GNSS Location + Open in Maps App + Opens in your preferred maps application + View in App + Show current location on an in-app map + No GPS fix yet — move to an open area + Location Map + Current Location + + + Settings + Language + Appearance + Dark theme + Sensor Streams + Phone GNSS + Accelerometer + Gyroscope + Magnetometer + Barometer + Units + Metric (m, km/h, hPa) + MQTT Broker + Broker IP + Port + Username + Password + optional + Show + Hide + Developer + Developer mode + Wi-Fi OBU connection + Dev mode only — not implemented + About + App version + 0.4.0 (Phase 02 — USB-C + DENM TX) + Connection + Auto-detect OBU via USB-C + Manual OBU IP + Active transport + USB-C + Wi-Fi + Save Broker Settings + + + V2X Monitor + Connect + Disconnect + Auto-scroll to latest + No messages yet + Connect to the OBU and wait for V2X traffic + No messages on this topic yet + + + Last transmitted payload: + DENM Transmission + Send Test DENM + Stop DENM + Aftermarket Stationary Vehicle (causeCode 94) + Connect to the OBU to enable DENM triggering + DENM active — OBU broadcasting via ITS-G5 + Publishes uca-denmctrl (retained) to v2x-uca/input/denmtrg + Show last TX + Hide + + + USB-C + Wi-Fi + BT + + + ⚠ OBU stationType ≠ 2 (cyclist) — VRU detection may be impaired at equipped intersections + + Platform + Android / Kotlin / Jetpack Compose + Project + MicrOBU — HAW Hamburg + + + Trips + + + Detected Events + Braking + Turning + Stopping + Event Detection + CSV Session Log + + + Trip History + %1$d trip stored on device + %1$d trips stored on device + Trip #%1$d + Delete trip? + This will permanently remove the trip recorded on %1$s. + No trips yet + Go to Record and start a session + + + Trip Review + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..c4263b9 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..89c386c --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml new file mode 100644 index 0000000..cb544e7 --- /dev/null +++ b/app/src/main/res/xml/locales_config.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/test/java/com/hawhamburg/micr0bu/EventDetectorTest.kt b/app/src/test/java/com/hawhamburg/micr0bu/EventDetectorTest.kt new file mode 100644 index 0000000..d6456f4 --- /dev/null +++ b/app/src/test/java/com/hawhamburg/micr0bu/EventDetectorTest.kt @@ -0,0 +1,354 @@ +package com.hawhamburg.micr0bu + +import com.hawhamburg.micr0bu.domain.detection.Confidence +import com.hawhamburg.micr0bu.domain.detection.DetectedEvent +import com.hawhamburg.micr0bu.domain.detection.DetectionConfig +import com.hawhamburg.micr0bu.domain.detection.EventDetector +import com.hawhamburg.micr0bu.domain.detection.EventType +import com.hawhamburg.micr0bu.domain.detection.RunningStats +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import kotlin.math.sqrt + +/** + * Unit tests for [EventDetector] and [RunningStats] using synthetic sensor data. + * + * No Android emulator required — all production classes have zero Android imports. + * + * The test [config] uses a smaller window and fewer sustained frames than the + * production defaults so tests run in milliseconds without generating thousands + * of synthetic samples. + * + * Accel-std-dev notes + * ------------------- + * A production threshold of 1.2 m/s² requires genuine variability in the window. + * In the "hard brake" tests we alternate between high and low accel values + * (e.g. 3.5 / 0.5), which yields std dev ≈ 1.5 with a 10-sample window. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class EventDetectorTest { + + /** Tighter config so fewer frames are needed to trigger each event. */ + private val config = DetectionConfig( + windowSize = 10, + brakingSustainedFrames = 5, + turningSustainedFrames = 8, + stoppingFrames = 20, + // Keep production thresholds for all signal values: + brakingSpeedDropThreshold = 0.5, + brakingAccelStdDevThreshold = 1.2, + brakingHighConfidenceRate = 1.5, + turningGyroMeanThreshold = 0.4, + turningBearingChangeThreshold = 10.0, + turningMinSpeedThreshold = 2.0, + stoppingSpeedThreshold = 0.5, + stoppingAccelStdDevThreshold = 0.15, + ) + + private lateinit var detector: EventDetector + + @Before fun setUp() { detector = EventDetector(config) } + + // ─── Helpers ────────────────────────────────────────────────────────────── + + /** Runs [block] inside a coroutine that collects all emitted events. */ + private fun runCollecting( + block: suspend kotlinx.coroutines.test.TestScope.(events: MutableList) -> Unit, + ) = runTest { + val events = mutableListOf() + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + detector.events.collect { events.add(it) } + } + block(events) + job.cancel() + } + + /** + * Produces [n] frames with alternating accelMagnitude values of [hi] and [lo], + * giving a population std dev of |hi - lo| / 2, which exceeds the production + * threshold of 1.2 m/s² when hi=3.5 and lo=0.5 (std dev = 1.5). + */ + private fun alternatingAccelFrames( + n: Int, + hi: Double = 3.5, + lo: Double = 0.5, + speedMps: Double = 10.0, + bearingChangeDps: Double = 0.0, + timeOffset: Int = 0, + ) { + repeat(n) { i -> + detector.processSample( + accelMagnitude = if (i % 2 == 0) hi else lo, + gyroMagnitude = 0.05, + speedMps = speedMps, + bearingChangeDegPerSec = bearingChangeDps, + latitude = 53.5, + longitude = 10.0, + timestamp = (timeOffset + i) * 20L, + ) + } + } + + // ─── Normal riding — no false triggers ─────────────────────────────────── + + @Test fun `normal riding produces no events`() = runCollecting { events -> + // Steady 5 m/s, low gyro, very low accel variance (constant value → stdDev = 0) + repeat(50) { i -> + detector.processSample( + accelMagnitude = 0.2, + gyroMagnitude = 0.1, + speedMps = 5.0, + bearingChangeDegPerSec = 2.0, + latitude = 53.5, + longitude = 10.0, + timestamp = i * 20L, + ) + } + assertTrue("No events expected during steady riding, got: $events", events.isEmpty()) + } + + // ─── Hard brake ─────────────────────────────────────────────────────────── + + @Test fun `hard brake triggers BRAKING event`() = runCollecting { events -> + // Phase 1: fill window at 10 m/s with constant accel (no std dev → no braking) + repeat(config.windowSize) { i -> + detector.processSample(1.0, 0.05, 10.0, 0.0, 53.5, 10.0, i * 20L) + } + // Phase 2: GPS drops to 4 m/s (drop = 6 m/s > 0.5 threshold). + // Alternate hi/lo accel to exceed the std-dev threshold. + alternatingAccelFrames( + n = config.brakingSustainedFrames + 5, + speedMps = 4.0, + timeOffset = config.windowSize, + ) + val braking = events.filter { it.type == EventType.BRAKING } + assertTrue("Expected at least one BRAKING event, got: $events", braking.isNotEmpty()) + } + + @Test fun `hard brake with large speed drop has HIGH confidence`() = runCollecting { events -> + repeat(config.windowSize) { i -> + detector.processSample(1.0, 0.05, 10.0, 0.0, 53.5, 10.0, i * 20L) + } + // Drop of 8 m/s > brakingHighConfidenceRate (1.5) + alternatingAccelFrames( + n = config.brakingSustainedFrames + 5, + hi = 3.5, + lo = 0.5, + speedMps = 2.0, // drop from 10 → 8 m/s + timeOffset = config.windowSize, + ) + val braking = events.filter { it.type == EventType.BRAKING } + assertTrue(braking.isNotEmpty()) + assertEquals( + "Large speed drop should yield HIGH confidence", + Confidence.HIGH, + braking.first().confidence, + ) + } + + @Test fun `moderate speed drop has MEDIUM confidence`() = runCollecting { events -> + repeat(config.windowSize) { i -> + detector.processSample(1.0, 0.05, 3.0, 0.0, 53.5, 10.0, i * 20L) + } + // Drop of 0.8 m/s — above speed-drop threshold (0.5) but below high-conf rate (1.5) + alternatingAccelFrames( + n = config.brakingSustainedFrames + 5, + hi = 3.5, + lo = 0.5, + speedMps = 2.2, // drop = 0.8 m/s + timeOffset = config.windowSize, + ) + val braking = events.filter { it.type == EventType.BRAKING } + assertTrue(braking.isNotEmpty()) + assertEquals(Confidence.MEDIUM, braking.first().confidence) + } + + // ─── Left turn ──────────────────────────────────────────────────────────── + + @Test fun `sustained high gyro above 2ms triggers TURNING event`() = runCollecting { events -> + val total = config.windowSize + config.turningSustainedFrames + 4 + repeat(total) { i -> + detector.processSample( + accelMagnitude = 0.3, + gyroMagnitude = 0.8, // mean → well above 0.4 threshold + speedMps = 4.0, // above 2 m/s → bearing also checked + bearingChangeDegPerSec = 15.0, // above 10 °/s → both signals agree + latitude = 53.5, + longitude = 10.0, + timestamp = i * 20L, + ) + } + assertTrue("Expected TURNING event, got: $events", events.any { it.type == EventType.TURNING }) + } + + @Test fun `turning with both signals agreeing gets HIGH confidence`() = runCollecting { events -> + val total = config.windowSize + config.turningSustainedFrames + 4 + repeat(total) { i -> + detector.processSample(0.3, 0.8, 4.0, 15.0, 53.5, 10.0, i * 20L) + } + val turning = events.filter { it.type == EventType.TURNING } + assertTrue(turning.isNotEmpty()) + assertEquals(Confidence.HIGH, turning.first().confidence) + } + + @Test fun `turning at low speed with gyro only gets LOW confidence`() = runCollecting { events -> + val total = config.windowSize + config.turningSustainedFrames + 4 + repeat(total) { i -> + detector.processSample( + accelMagnitude = 0.2, + gyroMagnitude = 0.6, // above gyro threshold + speedMps = 1.0, // below 2 m/s → bearing not enforced + bearingChangeDegPerSec = 3.0, // below bearing threshold + latitude = 53.5, + longitude = 10.0, + timestamp = i * 20L, + ) + } + val turning = events.filter { it.type == EventType.TURNING } + assertTrue("Expected TURNING event at low speed, got: $events", turning.isNotEmpty()) + assertEquals("Low-speed turn should be LOW confidence", Confidence.LOW, turning.first().confidence) + } + + // ─── Full stop ──────────────────────────────────────────────────────────── + + @Test fun `full stop for required frames triggers STOPPING event`() = runCollecting { events -> + val frames = config.stoppingFrames + 5 + repeat(frames) { i -> + detector.processSample( + accelMagnitude = 0.02, // constant → stdDev = 0 < 0.15 + gyroMagnitude = 0.01, + speedMps = 0.1, // < 0.5 threshold + bearingChangeDegPerSec = 0.0, + latitude = 53.5, + longitude = 10.0, + timestamp = i * 20L, + ) + } + val stopping = events.filter { it.type == EventType.STOPPING } + assertTrue("Expected STOPPING event, got: $events", stopping.isNotEmpty()) + assertEquals("STOPPING should always be HIGH confidence", Confidence.HIGH, stopping.first().confidence) + } + + @Test fun `STOPPING emitted exactly once per stop episode`() = runCollecting { events -> + val frames = config.stoppingFrames + 30 + repeat(frames) { i -> + detector.processSample(0.02, 0.01, 0.1, 0.0, 53.5, 10.0, i * 20L) + } + val stopping = events.filter { it.type == EventType.STOPPING } + assertEquals("STOPPING must emit exactly once per episode", 1, stopping.size) + } + + // ─── Bag movement while stationary ─────────────────────────────────────── + + @Test fun `bag movement while stationary does not trigger BRAKING or TURNING`() = runCollecting { events -> + // Speed stays at zero; occasional accel/gyro spikes from bag jostle + repeat(50) { i -> + val accel = if (i % 5 == 0) 1.8 else 0.3 // jitter but mean is below std-dev threshold + val gyro = if (i % 7 == 0) 0.35 else 0.05 // occasional spike but mean stays < 0.4 + detector.processSample( + accelMagnitude = accel, + gyroMagnitude = gyro, + speedMps = 0.0, + bearingChangeDegPerSec = 1.0, + latitude = 53.5, + longitude = 10.0, + timestamp = i * 20L, + ) + } + // speed = 0 → no speed drop possible → no BRAKING + // gyro mean stays below 0.4 (only 1/7 frames spike to 0.35) → no TURNING + val unwanted = events.filter { it.type == EventType.BRAKING || it.type == EventType.TURNING } + assertTrue("Bag movement must not trigger BRAKING or TURNING, got: $events", unwanted.isEmpty()) + } + + // ─── Starting from stationary ───────────────────────────────────────────── + + @Test fun `starting from stationary does not produce false BRAKING event`() = runCollecting { events -> + // Begin at rest then accelerate — speed only INCREASES + repeat(10) { i -> + detector.processSample(0.1, 0.05, 0.0, 0.0, 53.5, 10.0, i * 20L) + } + repeat(20) { i -> + val speed = i * 0.3 // 0 → 5.7 m/s — monotonically increasing + detector.processSample(0.8, 0.1, speed, 1.0, 53.5, 10.0, (10 + i) * 20L) + } + val braking = events.filter { it.type == EventType.BRAKING } + assertTrue("Starting from stationary must not trigger BRAKING, got: $events", braking.isEmpty()) + } + + @Test fun `stopping then re-accelerating then stopping triggers two STOPPING events`() = runCollecting { events -> + val stopFrames = config.stoppingFrames + 5 + var t = 0 + + // First stop episode + repeat(stopFrames) { + detector.processSample(0.02, 0.01, 0.1, 0.0, 53.5, 10.0, t++ * 20L) + } + // Brief acceleration — resets the stopping counter + repeat(5) { + detector.processSample(0.5, 0.1, 5.0, 2.0, 53.5, 10.0, t++ * 20L) + } + // Second stop episode + repeat(stopFrames) { + detector.processSample(0.02, 0.01, 0.1, 0.0, 53.5, 10.0, t++ * 20L) + } + + val stopping = events.filter { it.type == EventType.STOPPING } + assertEquals("Expected two STOPPING events (one per episode)", 2, stopping.size) + } + + // ─── RunningStats unit tests ────────────────────────────────────────────── + + @Test fun `RunningStats mean and stdDev are correct for known sequence`() { + val stats = RunningStats(windowSize = 4) + // [2, 4, 4, 4] → mean = 3.5, variance = 0.75 + stats.add(2.0); stats.add(4.0); stats.add(4.0); stats.add(4.0) + assertEquals(3.5, stats.mean(), 1e-9) + assertEquals(0.75, stats.variance(), 1e-9) + assertEquals(sqrt(0.75), stats.stdDev(), 1e-9) + } + + @Test fun `RunningStats evicts oldest value when window is full`() { + val stats = RunningStats(windowSize = 3) + stats.add(1.0); stats.add(2.0); stats.add(3.0) + assertEquals(2.0, stats.mean(), 1e-9) // [1,2,3] → 2.0 + stats.add(10.0) + assertEquals(5.0, stats.mean(), 1e-9) // [2,3,10] → 5.0 + } + + @Test fun `RunningStats size grows correctly and caps at windowSize`() { + val stats = RunningStats(windowSize = 5) + assertEquals(0, stats.size()) + stats.add(1.0); assertEquals(1, stats.size()) + stats.add(1.0); assertEquals(2, stats.size()) + repeat(10) { stats.add(1.0) } + assertEquals(5, stats.size()) + } + + @Test fun `RunningStats reset clears all state`() { + val stats = RunningStats(windowSize = 5) + repeat(5) { stats.add(it.toDouble()) } + stats.reset() + assertEquals(0, stats.size()) + assertEquals(0.0, stats.mean(), 1e-9) + assertEquals(0.0, stats.stdDev(), 1e-9) + } + + @Test fun `RunningStats variance is zero for constant sequence`() { + val stats = RunningStats(windowSize = 10) + repeat(10) { stats.add(3.14) } + assertEquals(0.0, stats.variance(), 1e-9) + } + + @Test fun `RunningStats returns zero mean and stdDev for empty window`() { + val stats = RunningStats(windowSize = 10) + assertEquals(0.0, stats.mean(), 1e-9) + assertEquals(0.0, stats.stdDev(), 1e-9) + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..0a066db --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.hilt) apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..bf2a159 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,7 @@ +org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError +android.suppressUnsupportedCompileSdk=36 +org.gradle.daemon=true +org.gradle.parallel=true +org.gradle.caching=true +android.useAndroidX=true +kotlin.code.style=official diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..35855cc --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,57 @@ +[versions] +agp = "8.7.3" +kotlin = "2.1.0" +ksp = "2.1.0-1.0.29" +hilt = "2.51.1" +junit = "4.13.2" +hiltNavigationCompose = "1.2.0" +datastore = "1.1.1" +pahoMqtt = "1.2.5" +room = "2.6.1" +appcompat = "1.7.0" +osmdroid = "6.1.18" +splashscreen = "1.0.1" +composeBom = "2024.12.01" +coreKtx = "1.15.0" +lifecycle = "2.8.7" +activityCompose = "1.9.3" +navigationCompose = "2.8.5" +playServicesLocation = "21.3.0" +coroutines = "1.9.0" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +compose-ui = { group = "androidx.compose.ui", name = "ui" } +compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutines" } +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } +hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } +datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +paho-mqtt = { group = "org.eclipse.paho", name = "org.eclipse.paho.client.mqttv3", version.ref = "pahoMqtt" } +room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +osmdroid = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" } +androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1e2fbf0 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..1d4e70b --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,17 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "MicrOBU" +include(":app")