Files
MicrOBU/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt
T
Ashin Walpola 1ad123a6f8 Make the event detector a CAM rate input, not a ride-stats readout
The detector's only live consumer is the CAM transmit-rate policy: every
emitted event calls CamTransmitLoop.onDetectedEvent, raising the beacon
rate from 1 Hz to the elevated rate for five seconds so nearby stations
get denser updates through a manoeuvre. Counting one's own braking events
is not a goal of this project, so the display is gone and the detector
stays: the live per-type counters and their notification text, the event
pins and detail sheet on the trip review map, and the event chip on the
history card. Events are still persisted and exported to CSV, which is
the only route to the tuning measurement section 11.3 says is missing.

Fix two defects found while documenting the detector.

TripRecordingService overrode nine of DetectionConfig's twelve parameters
in its constructor, so the tests validated the Phase A defaults while the
phone ran something materially less sensitive. The tuned values are now
the defaults and the override is deleted; the numbers moved location, not
value, so detector sensitivity is unchanged. EventDetectorTest now sets
only windowSize and the sustained-frame counts and inherits every signal
threshold, which cannot drift again. That was not a free change and makes
the same point from the other side: at the real thresholds the old stimuli
triggered nothing. Accel alternating 3.5/0.5 gives a std dev of 1.5 and
never clears 1.8, and the moderate-braking case used a 0.8 m/s drop that
never clears 1.0. Those stimuli are re-derived against the real values.

brakingHighConfidenceRate was documented as a rate but has always been
compared against the peak cumulative drop from the onset speed, which
grows with episode length, so HIGH was assigned more readily than the name
implied. Renamed to brakingHighConfidencePeakDrop rather than changing the
comparison: "lost more than 1.5 m/s in one episode" is coherent, whereas a
rate off a 1 Hz speed signal sampled at 50 Hz spikes on a near-zero
divisor early in an episode. Output is unchanged, so the existing
confidence assertions stay evidence instead of being re-baselined.

Docs 11.3/11.4 updated in place, including the correction of a claim that
detected events do not reach the V2X side; the rate-bump path already
existed when that was written. 55 tests, 0 failures.
2026-09-08 16:20:14 +02:00

351 lines
20 KiB
Kotlin

package com.hawhamburg.micr0bu
import android.Manifest
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.appcompat.app.AppCompatActivity
import androidx.activity.SystemBarStyle
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.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
import com.hawhamburg.micr0bu.data.transport.ObuHardware
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.screens.UseCaseAlertsSettingsScreen
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 obuStationType by mqttViewModel.obuStationType.collectAsState()
val tripServiceState by tripViewModel.serviceState.collectAsState()
val showBatteryOptPrompt by tripViewModel.showBatteryOptPrompt.collectAsState()
val useCaseEnabledMap by mqttViewModel.useCaseEnabledMap.collectAsState()
val obuHardware by mqttViewModel.obuHardware.collectAsState()
val usbSerialState by mqttViewModel.usbSerialState.collectAsState()
// Received hazards and live signal state, for the Dashboard's V2X summary cards.
// Both flows already expire their own entries on a clock, so nothing here has to
// decide when a hazard or a traffic light has gone stale.
val denmEvents by mqttViewModel.denmEvents.collectAsState()
val spatIntersections by mqttViewModel.spatIntersections.collectAsState()
val ownCamPosition by mqttViewModel.ownCamPosition.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 = state,
mqttConnectionState = mqttConnectionState,
isEsp32 = obuHardware == ObuHardware.ESP32_C5,
usbSerialState = usbSerialState,
)
},
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,
obuHardware = obuHardware,
usbSerialState = usbSerialState,
usbCableConnected = usbConnected,
obuStationTypeWarning = obuStationTypeWarning,
obuStationType = obuStationType,
hazards = denmEvents,
signals = spatIntersections,
ownPosition = ownCamPosition,
onNavigateToConnection = { navController.navigate(Screen.Connection.route) },
onNavigateToSensors = {
navController.navigate(Screen.Sensors.route) {
popUpTo(Screen.Dashboard.route)
launchSingleTop = true
}
},
onNavigateToMap = { navController.navigate(Screen.Map.route) },
onNavigateToRecord = {
navController.navigate(Screen.Record.route) {
popUpTo(Screen.Dashboard.route)
launchSingleTop = true
}
},
// Same options the bottom bar uses, so arriving at V2X from a
// Dashboard card leaves the same back stack as tapping the tab.
onNavigateToV2x = {
navController.navigate(Screen.MqttViewer.route) {
popUpTo(Screen.Dashboard.route)
launchSingleTop = true
}
},
)
}
composable(Screen.Sensors.route) {
SensorScreen(state = state)
}
composable(Screen.Record.route) {
RecordingScreen(
state = state,
mqttConnectionState = mqttConnectionState,
obuConnected = if (obuHardware == ObuHardware.ESP32_C5)
usbSerialState == UsbSerialState.CONNECTED
else
mqttConnectionState == MqttConnectionState.CONNECTED,
tripServiceState = tripServiceState,
showBatteryOptPrompt = showBatteryOptPrompt,
onToggleRecording = {
// Start / stop both CSV recording and the event detection
// service. Order matters on start: the CSV session must exist
// before the trip row is written, so the trip can store its
// id and later delete the CSV along with itself.
viewModel.toggleRecording()
tripViewModel.toggleRecording(viewModel.activeSessionId)
},
onOpenSessionLog = {
navController.navigate(Screen.Log.route)
},
onOpenBatterySettings = {
startActivity(
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:$packageName")
}
)
tripViewModel.recheckBatteryOpt()
},
onDismissBatteryOptPrompt = tripViewModel::dismissBatteryOptPrompt,
)
}
composable(Screen.Log.route) {
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)
}
}
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) },
onOpenUseCaseAlerts = { navController.navigate(Screen.SettingsUseCaseAlerts.route) },
onOpenDeveloper = { navController.navigate(Screen.SettingsDeveloper.route) },
onOpenAbout = { navController.navigate(Screen.SettingsAbout.route) },
)
}
composable(Screen.SettingsUseCaseAlerts.route) {
UseCaseAlertsSettingsScreen(
enabledMap = useCaseEnabledMap,
onToggle = mqttViewModel::setUseCaseEnabled,
onBack = { navController.popBackStack() },
)
}
composable(Screen.SettingsConnection.route) {
ConnectionSettingsScreen(
mqttPrefs = mqttPrefs,
onMqttPrefsChange = mqttViewModel::updatePrefs,
obuHardware = obuHardware,
onObuHardwareChange = mqttViewModel::setObuHardware,
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,
onOpenSensorMonitor = { navController.navigate(Screen.Sensors.route) },
onBack = { navController.popBackStack() },
)
}
composable(Screen.SettingsAbout.route) {
AboutSettingsScreen(onBack = { navController.popBackStack() })
}
composable(Screen.Connection.route) {
// Must be the Activity-scoped instance (same one MainActivity holds
// and MqttTopicViewerScreen is given below), not the default
// hiltViewModel() — that would create a separate instance scoped to
// this NavBackStackEntry, whose onCleared() (fired the moment you
// navigate away) would disconnect the shared UsbSerialTransport out
// from under every other screen still using.
ConnectionSetupScreen(viewModel = mqttViewModel)
}
composable(Screen.Map.route) {
MapScreen(gnss = state.gnss)
}
}
}
}
}
}
}